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…

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 sqlite3 and 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

python
# 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.

From a map library's tile request to a row in the containerThe map library requests a tile by zoom, column and row through a URL template. The endpoint translates the row index if the pyramid's convention differs from the URL scheme's, then performs a single primary-key lookup against the tile table. A found tile is returned with its image content type and a long cache header; an absent tile returns 204 No Content, which tells the client to draw nothing rather than to report an error.map library/{z}/{x}/{y}.pngrow translationonly if conventions differone SELECTprimary-key lookup200 + image204, no body
Four steps, one query, and the only judgement call is which status an absent tile should get.

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.

python
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.

python
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.

python
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

html
<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.

What each response status tells the map libraryA 200 with image data draws the tile. A 204 with no body tells the client the tile is legitimately absent, and it draws nothing without logging an error. A 404 is interpreted as a fault, which produces console errors and in some configurations a retry. A 500 causes the client to treat the whole source as broken. For a sparse pyramid, the difference between 204 and 404 is the difference between a clean map and a console full of errors.200 + imagedraws the tile — the ordinary case204, no bodydraws nothing, logs nothing — correct for a sparse pyramid404treated as a fault — console errors, sometimes a retry500the whole source is marked broken
Returning 404 for an absent tile is the single most common reason an otherwise correct offline map fills the console with errors.

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.

python
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

bash
# 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:

python
# 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.

What to cache, and whereThe tile matrix rows are read once per zoom level and never change, so they belong in an in-process cache. Tile bytes never change for the life of a container, so they belong in the client's HTTP cache with a long immutable max-age. The TileJSON metadata changes only when the container is replaced, so a short cache is right. Replacing a container means changing the URL prefix rather than invalidating anything.tile matrix rows — in-process cacheone lookup per zoom level, never changes while the container is opentile bytes — client HTTP cache, immutablenever change for the life of the container · removes almost all repeat requestsTileJSON — short cachechanges only when the container is replaced · version the URL rather than invalidate
Immutability is the property that makes tile caching easy: nothing served here is ever revised in place.

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.