How to Serve GeoPackage Tiles Offline
Expose a /{z}/{x}/{y}.png route backed by one SELECT against the tile table, translate the row index if the pyramid uses a different convention from the URL scheme, and return 204 No Content for a tile that is legitimately absent. Sixty lines of Python turn a container into something MapLibre, Leaflet or OpenLayers renders with no network at all.
This page belongs to the Raster Tiles & Coverage in GeoPackage guide.
Why This Matters
Web map libraries speak one language for tiles: a URL template with {z}, {x} and {y} placeholders. A GeoPackage speaks rows in a table. Bridging the two is the difference between a container that is a data file and one that is a working offline map, and the bridge is small enough that reaching for a full tile server is usually overkill.
The part that needs care is the row index. The standard web-tile scheme numbers rows downward from the top, and so does GeoPackage — but the widely-used TMS variant numbers them upward, and a pyramid built by a tool that emits TMS needs translating at the boundary. Getting this wrong gives a vertically mirrored map, which looks like a projection fault and is not.
Prerequisites
- Python 3.9+ with
sqlite3and any WSGI or ASGI framework — the example uses Flask - A GeoPackage containing a tile pyramid, per Writing a Tile Pyramid into a GeoPackage
- A map library that accepts a URL template
- The pyramid’s tiling scheme, since it decides whether translation is needed
Primary Method
# app.py — a z/x/y endpoint over a GeoPackage pyramid
import sqlite3
from flask import Flask, Response, abort
GPKG = "basemap.gpkg"
TABLE = "basemap"
app = Flask(__name__)
def connect() -> sqlite3.Connection:
# Read-only, and one connection per request keeps threading simple
return sqlite3.connect(f"file:{GPKG}?mode=ro", uri=True)
@app.route("/tiles/<int:z>/<int:x>/<int:y>.png")
def tile(z: int, x: int, y: int):
conn = connect()
try:
row = conn.execute(
f'SELECT tile_data FROM "{TABLE}" '
"WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
(z, x, y),
).fetchone()
finally:
conn.close()
if row is None:
# A sparse pyramid is normal — 204 tells the client to draw nothing
return Response(status=204)
return Response(
row[0],
mimetype="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
The 204 rather than 404 matters for how clients behave. A 404 makes most map libraries log an error and, in some configurations, retry; a 204 says “nothing here, and that is fine”, which is exactly what a gap in a sparse pyramid means.
Step-by-Step Walkthrough
1. Determine whether the row index needs translating
A pyramid built with TILING_SCHEME=GoogleMapsCompatible uses the same downward row numbering as the standard web-tile URL scheme, so no translation is needed. A pyramid built from a TMS source counts upward, and y must be flipped against the grid height for that zoom level.
def flip_row(conn, table: str, z: int, y: int) -> int:
"""Convert a TMS row index into the GeoPackage convention."""
(height,) = conn.execute(
"SELECT matrix_height FROM gpkg_tile_matrix "
"WHERE table_name = ? AND zoom_level = ?",
(table, z),
).fetchone()
return height - 1 - y
Because matrix_height differs per level, the flip must be computed per request rather than with a constant. That is the mistake that produces a map correct at one zoom and mirrored at every other.
2. Cache the matrix rows
Looking up matrix_height on every tile request is a query per tile, which on a panning map is thousands of pointless round trips. The matrix is tiny and never changes while the container is open.
from functools import lru_cache
@lru_cache(maxsize=None)
def matrix_height(table: str, z: int) -> int:
conn = connect()
try:
(h,) = conn.execute(
"SELECT matrix_height FROM gpkg_tile_matrix "
"WHERE table_name = ? AND zoom_level = ?", (table, z)
).fetchone()
return h
finally:
conn.close()
3. Serve the layer metadata a client needs
Map libraries want to know the extent, the zoom range and the reference system before they start requesting tiles. One endpoint returning that saves hard-coding it into the client.
from flask import jsonify
@app.route("/tiles/<table>.json")
def tilejson(table: str):
conn = connect()
try:
s = conn.execute(
"SELECT min_x, min_y, max_x, max_y, srs_id "
"FROM gpkg_tile_matrix_set WHERE table_name = ?", (table,)
).fetchone()
if s is None:
abort(404)
zmin, zmax = conn.execute(
"SELECT min(zoom_level), max(zoom_level) FROM gpkg_tile_matrix "
"WHERE table_name = ?", (table,)
).fetchone()
finally:
conn.close()
return jsonify({
"tilejson": "3.0.0",
"tiles": [f"/tiles/{{z}}/{{x}}/{{y}}.png"],
"minzoom": zmin,
"maxzoom": zmax,
"bounds": [s[0], s[1], s[2], s[3]],
"scheme": "xyz",
})
The bounds field must be in longitude and latitude regardless of the pyramid’s own reference system, which is a detail the TileJSON specification fixes and a common source of a map that refuses to show anything.
4. Point the map library at it
<script>
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
basemap: {
type: "raster",
tiles: ["http://127.0.0.1:8000/tiles/{z}/{x}/{y}.png"],
tileSize: 256,
minzoom: 8,
maxzoom: 16,
},
},
layers: [{ id: "basemap", type: "raster", source: "basemap" }],
},
center: [-1.99, 52.19],
zoom: 12,
});
</script>
Declaring minzoom and maxzoom to match the pyramid stops the client requesting levels that do not exist, which otherwise produces a stream of 204s and a blank map outside the built range.
5. Bind to localhost and keep it read-only
An offline tile endpoint has no business accepting connections from elsewhere, and it has no business writing.
if __name__ == "__main__":
# localhost only; the container is opened read-only per request
app.run(host="127.0.0.1", port=8000, threaded=True)
Opening with mode=ro also means the endpoint cannot be the cause of a lock on a container a field application is reading at the same time.
Verification
# A tile that exists
curl -sI http://127.0.0.1:8000/tiles/12/2045/1362.png | head -3
# HTTP/1.1 200 OK
# Content-Type: image/png
# A tile that does not — must be 204, not 404
curl -sI http://127.0.0.1:8000/tiles/12/0/0.png | head -1
# HTTP/1.1 204 NO CONTENT
And confirm the row convention with a visual check that does not require rendering a whole map:
# The northernmost stored row at a level must be the lowest y in the URL scheme
conn = connect()
z = 12
lo, hi = conn.execute(
"SELECT min(tile_row), max(tile_row) FROM basemap WHERE zoom_level = ?", (z,)
).fetchone()
north = requests.get(f"http://127.0.0.1:8000/tiles/{z}/{col}/{lo}.png")
south = requests.get(f"http://127.0.0.1:8000/tiles/{z}/{col}/{hi}.png")
assert north.status_code == 200 and south.status_code == 200
# Compare against the source raster: the lo-row tile must be the northern one
If the two are the wrong way round, the pyramid and the URL scheme disagree and flip_row is needed.
Alternative Approaches or Edge Cases
Serving directly from the application. A desktop or mobile application that embeds a map view can usually intercept tile requests through the view’s own API rather than running an HTTP server at all, which removes a port, a process and a class of security question. Where the map library supports a custom protocol handler, prefer it.
Serving vector tiles. A GeoPackage can hold vector tiles through an extension, in which case the same endpoint shape applies with Content-Type: application/x-protobuf and no image decoding. The addressing and the row convention are identical.
Multiple pyramids in one container. Parameterising the table name in the route lets one endpoint serve every pyramid a container holds — useful when a basemap and an overlay ship together. Validate the table name against gpkg_contents rather than interpolating it, or the route becomes a way to read arbitrary tables.
Troubleshooting
The map is mirrored vertically
Cause: The pyramid and the URL scheme disagree about row direction. Fix: Apply flip_row, computing the height per zoom level rather than once.
Every tile returns 204
Cause: The zoom levels requested are outside those built, or the coordinates are outside the pyramid’s extent. Fix: Compare the client’s minzoom/maxzoom against gpkg_tile_matrix, and check the bounds in the TileJSON are longitude and latitude rather than the pyramid’s own units.
The endpoint is slow while panning
Cause: A matrix lookup per tile, or a new connection per tile with no reuse. Fix: Cache the matrix as in step 2. The tile lookup itself is a primary-key hit and is not the bottleneck.
Frequently Asked Questions
Is an HTTP server really needed for an offline map?
Only if the map library insists on URLs, which most do. The alternative — intercepting tile requests inside the application — is cleaner where the platform supports it, because it avoids binding a port and removes the question of what else on the machine can reach it. For a desktop tool or a quick visualisation, the small server is the path of least resistance.
Should tiles be cached by the client?
Aggressively, because they never change. A container’s tiles are immutable for the life of that container, so a long max-age with immutable is correct and removes almost all repeat requests during panning. When the container is replaced, change the URL prefix rather than trying to invalidate — a version segment in the path is the simplest mechanism.
Can the same endpoint serve feature data?
It can, and it is usually better to keep them separate. Tiles are immutable and cacheable forever; features change and need different cache semantics. Serving features as GeoJSON from a second route with its own caching rules keeps both correct, rather than compromising on headers that suit neither.
Related
- Raster Tiles & Coverage in GeoPackage — parent guide: the pyramid model
- How to Read a Raster Tile from a GeoPackage in Python — the query behind the route
- GeoPackage Tile Matrix Sets Explained — where
matrix_heightcomes from - Read-Only WAL Mode for Distributed Field Devices — keeping the served container genuinely read-only