Serving MBTiles with Python

When you need a running endpoint — for access control, dynamic layer selection, or fitting tiles into an existing API — an MBTiles archive served by a small Python app is the pragmatic choice. This guide builds a minimal FastAPI tile server that reads tiles straight from a SQLite MBTiles file and streams them to a MapLibre GL vector web map. It is for developers who can't use a purely static PMTiles host. It sits under Vector Tile Pipelines with PMTiles in Web Mapping & Interactive Visualization.

Why This Approach / What Goes Wrong

MBTiles is a SQLite database: one tiles table maps zoom_level / tile_column / tile_row to a blob of MVT (or PNG) bytes, and a metadata table holds the format, zoom range, and bounds. Serving it is just one parameterized query per tile request. The single detail that breaks every first attempt is the TMS vs XYZ row flip: MBTiles stores tiles in the TMS scheme (row 0 at the bottom), while web clients — Leaflet, MapLibre, OpenLayers — request tiles in XYZ (row 0 at the top). You must convert y on every request, or the map shows the right tiles in the wrong places, typically mirrored vertically about the equator.

The conversion is a one-liner, tms_y = (2 ** z) - 1 - y, but it is easy to apply zero times or twice. Applying it twice is as broken as not applying it at all, so it belongs in exactly one place in the code path.

XYZ versus TMS tile row order at zoom 2 A single physical tile at column 2. In the XYZ grid the client addresses it as y=1 with row 0 at the top; in the TMS grid MBTiles stores it as row 2 with row 0 at the bottom. The formula tms_y equals two to the power z minus one minus y maps one to the other, giving 2 when z is 2 and y is 1. XYZ — what web clients request TMS — how MBTiles stores rows x=0 1 2 3 x=0 1 2 3 y=0 1 2 3 y=1 origin (0,0) y increases down 3 2 1 row=0 row 2 origin (0,0) row increases up same physical tile tms_y = (2ⁿ − 1) − y at z = 2: tms_y = (4 − 1) − 1 = 2 · apply exactly once, in the SQL query only
XYZ versus TMS tile row order at zoom 2

The second thing that breaks is subtler, because it surfaces only under load: not every .mbtiles file has the same physical schema. The specification allows tiles to be a real table or a view. Tippecanoe writes a plain table; mbutil, gdal2tiles and several desktop exporters write the deduplicating layout instead — a map table mapping z/x/y to a tile_id, joined to an images table mapping tile_id to the blob, with tiles exposed as a view over that join so the same query still works. It does work, but a view whose underlying tables are unindexed turns every tile request into a join across the whole archive. The companion trap is that the conventional tile_index unique index on (zoom_level, tile_column, tile_row) is a convention, not a requirement: without it SQLite answers each request with a full scan of a table that may hold ten million rows. Both faults look perfect against a 5 MB test file and collapse the moment a real tileset is behind the endpoint.

A static PMTiles archive built from GeoParquet avoids running any server at all — the client reads byte ranges directly from object storage. Reach for MBTiles serving only when you genuinely need server-side logic: authenticating requests, selecting layers per user, or joining tiles into an API you already run. If you only need to drop a few thousand features on a page, an interactive Folium map is simpler still.

Decision tree for choosing a tile delivery mode The root question asks whether per-request server logic is needed for authentication, filtering or dynamic layers. Answering no leads to public read-only tiles, which split by data volume into a Folium map for a few thousand inline features, or a static PMTiles archive on object storage for millions of features. Answering yes leads to authentication or per-user layers, whose only option is a FastAPI app reading MBTiles with one SQLite query per tile and the row flip applied exactly once. Only that right-hand branch requires a running process. Choosing the delivery mode Do you need per-request logic? auth, filtering, dynamic layers no yes Public, read-only tiles nothing decided per request Auth or per-user layers or tiles inside an existing API how much data? Folium map a few thousand features inline Static PMTiles millions of features, object storage only FastAPI + MBTiles one SQLite read per tile y-flip applied exactly once Only the right-hand branch needs a process running.
Server-side logic is the one requirement that justifies a running tile endpoint; every other case is cheaper and more durable as a static archive.

Prerequisites

pip install "fastapi>=0.110" "uvicorn[standard]>=0.29" "requests>=2.31"

Step-by-Step Implementation

1. Open the MBTiles read-only and read its metadata. Opening with mode=ro and check_same_thread=False lets multiple worker threads share one connection safely for reads and sidesteps the database is locked error.

import sqlite3

mbtiles_path = "parcels.mbtiles"
conn = sqlite3.connect(
    f"file:{mbtiles_path}?mode=ro", uri=True, check_same_thread=False
)

metadata = dict(conn.execute("SELECT name, value FROM metadata").fetchall())
print(metadata.get("format"), metadata.get("minzoom"), metadata.get("maxzoom"))
# pbf 0 14

The format value (pbf for gzipped MVT, or png/jpg for raster) decides the Content-Type and whether tiles are gzip-encoded. The bounds and center values, when present, let you build a TileJSON document in step 4.

2. Audit the physical schema and prove the index is used. Two cheap queries decide whether every later tile request is an indexed constant-time read or a full scan. Run them once at startup against any archive you did not build yourself.

for kind, name in conn.execute(
    "SELECT type, name FROM sqlite_master "
    "WHERE name IN ('tiles', 'map', 'images', 'tile_index', 'map_index')"
).fetchall():
    print(f"{kind:5} {name}")
# table tiles
# index tile_index

# EXPLAIN QUERY PLAN shows how SQLite will actually resolve a tile lookup
plan = conn.execute(
    "EXPLAIN QUERY PLAN SELECT tile_data FROM tiles "
    "WHERE zoom_level=12 AND tile_column=2138 AND tile_row=1450"
).fetchall()
print(plan[0][-1])
# SEARCH tiles USING INDEX tile_index (zoom_level=? AND tile_column=? AND tile_row=?)

A plan that begins SCAN tiles instead of SEARCH tiles USING INDEX is the entire diagnosis for a tile endpoint that answers in 400 ms rather than a fraction of a millisecond. The repair is one statement, run once against a writable copy at build time — never per request, and never against an archive workers already have open:

sqlite3 parcels.mbtiles \
  "CREATE UNIQUE INDEX IF NOT EXISTS tile_index ON tiles (zoom_level, tile_column, tile_row);"

If the audit reported view tiles instead of table tiles, the archive uses the deduplicating layout and the index belongs on the base tables: CREATE UNIQUE INDEX map_index ON map (zoom_level, tile_column, tile_row) plus CREATE UNIQUE INDEX images_id ON images (tile_id). Deduplication is worth keeping for tilesets with large uniform areas — an ocean tile is stored once and referenced thousands of times — so convert the schema only if you have measured the join and found it wanting.

3. Serve tiles, flipping the y axis from XYZ to TMS. This is the core handler. The row flip appears exactly once, and a missing tile returns 204 No Content rather than an error, because empty tiles over no-data areas are normal.

from fastapi import FastAPI, Response

app = FastAPI()
is_vector = metadata.get("format") == "pbf"

@app.get("/tiles/{z}/{x}/{y}.{ext}")
def get_tile(z: int, x: int, y: int, ext: str):
    tms_y = (1 << z) - 1 - y          # XYZ → TMS row flip, applied once
    row = conn.execute(
        "SELECT tile_data FROM tiles "
        "WHERE zoom_level=? AND tile_column=? AND tile_row=?",
        (z, x, tms_y),
    ).fetchone()
    if row is None:
        return Response(status_code=204)       # empty tile, not an error
    headers = {
        "Access-Control-Allow-Origin": "*",
        "Cache-Control": "public, max-age=86400",
    }
    media = "application/x-protobuf" if is_vector else "image/png"
    if is_vector:
        headers["Content-Encoding"] = "gzip"   # tippecanoe stores gzipped MVT
    return Response(content=row[0], media_type=media, headers=headers)
Lifecycle of one tile request through the FastAPI handler Three participants are shown as columns: the MapLibre GL client, the FastAPI tile route, and the read-only SQLite MBTiles connection. The client issues a GET for tile z 12, x 2138, y 1450. Inside the route the row flip tms_y equals one shifted left by z, minus one, minus y is applied exactly once. The route then runs a single SELECT of tile_data by zoom level, column and row, and SQLite returns a gzipped vector tile blob. If no row comes back the route answers 204, which is an empty tile rather than an error. Otherwise the client receives a 200 with the protobuf media type, gzip content encoding and a one-day cache header. MapLibre GL client FastAPI tile route SQLite MBTiles (mode=ro) GET /tiles/12/2138/1450.pbf tms_y = (1 << z) - 1 - y the flip — exactly once SELECT tile_data WHERE zoom_level=? row → gzipped MVT blob no row → 204 empty tile, not an error 200 · x-protobuf · gzip · max-age=86400
One request, one row flip, one SQLite read: keeping the flip inside the query is what stops it from being applied zero times or twice.

4. Run it. Point a browser or curl at a known tile to confirm bytes come back.

uvicorn tile_server:app --port 8080
# Tiles available at http://localhost:8080/tiles/{z}/{x}/{y}.pbf

5. Give each worker thread its own immutable connection, and answer repeats with a 304. One shared connection is fine for a demo, but calls against it serialize, so four uvicorn workers end up no faster than one. A threading.local() handle gives every worker thread its own connection to the same file, and the immutable=1 URI flag promises SQLite the file will not change while it is open — true by construction for a tileset you replace rather than edit. SQLite then skips locking, journal recovery and stat() checks entirely, and PRAGMA mmap_size lets it read tile blobs straight out of the page cache with no copy into user space.

import hashlib
import sqlite3
import threading

from fastapi import Header, Response

_local = threading.local()

def tile_conn() -> sqlite3.Connection:
    conn = getattr(_local, "conn", None)
    if conn is None:
        # immutable=1 is a promise: nothing may write this file while it is open
        conn = sqlite3.connect(f"file:{mbtiles_path}?immutable=1", uri=True)
        conn.execute("PRAGMA mmap_size = 268435456")   # 256 MB mapped read window
        _local.conn = conn
    return conn

@app.get("/v2/tiles/{z}/{x}/{y}.pbf")
def get_tile_v2(z: int, x: int, y: int, if_none_match: str | None = Header(default=None)):
    row = tile_conn().execute(
        "SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
        (z, x, (1 << z) - 1 - y),
    ).fetchone()
    if row is None:
        return Response(status_code=204)               # no body, no exception
    etag = '"%s"' % hashlib.blake2b(row[0], digest_size=8).hexdigest()
    if if_none_match == etag:
        return Response(status_code=304)               # ~40 bytes instead of ~40 KB
    return Response(
        content=row[0],
        media_type="application/x-protobuf",
        headers={
            "ETag": etag,
            "Content-Encoding": "gzip",
            "Cache-Control": "public, max-age=86400",
            "Access-Control-Allow-Origin": "*",
        },
    )

Two details there earn their keystrokes. Returning Response(status_code=204) instead of raising HTTPException(204) matters because a raised exception is rendered as a JSON body, and a 204 carrying a body is a protocol violation that h11 refuses to transmit — the symptom is intermittent 500s over empty ocean tiles rather than a clean skip. And an ETag computed from the tile bytes themselves lets a returning visitor revalidate for the price of a header round-trip; because the digest is over content, it stays correct across rebuilds with no version scheme to maintain. What happens to those headers at the edge is a separate decision, worked through in Deploying a Python Map App Behind a CDN.

With a warm page cache, an indexed lookup plus response construction costs roughly 0.1–0.3 ms, so a single worker saturates in the low thousands of tiles per second and the ASGI stack, not SQLite, is the ceiling. The design stops scaling on two axes. The first is size: once the archive is larger than free RAM the page cache stops absorbing reads and each miss becomes a disk seek, which on network-attached storage is 100× worse than local NVMe. The second is the existence of the process at all — if nothing about a request is decided per user, the archive belongs on object storage as PMTiles and the whole scaling question disappears.

6. Expose a TileJSON endpoint so clients can self-configure. MapLibre and Leaflet can read a single TileJSON URL and derive the tile template, zoom range, and bounds from it — cleaner than hardcoding those on the client. Build it from the MBTiles metadata you already read.

from fastapi import Request

@app.get("/tiles.json")
def tilejson(request: Request):
    base = str(request.base_url).rstrip("/")
    ext = "pbf" if is_vector else "png"
    bounds = metadata.get("bounds", "-180,-85,180,85")
    doc = {
        "tilejson": "3.0.0",
        "name": metadata.get("name", "parcels"),
        "tiles": [f"{base}/tiles///.{ext}"],
        "minzoom": int(metadata.get("minzoom", 0)),
        "maxzoom": int(metadata.get("maxzoom", 14)),
        "bounds": [float(v) for v in bounds.split(",")],
    }
    if is_vector and "json" in metadata:
        # tippecanoe writes vector_layers into the metadata "json" field
        import json
        doc["vector_layers"] = json.loads(metadata["json"]).get("vector_layers", [])
    return doc

7. Point MapLibre at the endpoint. Reference the TileJSON url rather than a raw tile template so the source picks up the zoom range and layer names automatically.

# In the page JS:
#   map.addSource("parcels", { type: "vector", url: "http://localhost:8080/tiles.json" });
#   map.addLayer({
#     id: "parcels-fill", type: "fill", source: "parcels",
#     "source-layer": "parcels",              // must match tippecanoe -l
#     paint: { "fill-color": "#3e5c76", "fill-opacity": 0.6 },
#   });

Only the attributes tippecanoe kept are available to the client, so any paint expression must reference a field that survived the tile build — the pattern is covered in Styling Vector Tiles with Data-Driven Expressions. A style that reads ["get", "area_ha"] against a tileset built without -y area_ha renders every polygon in the fallback colour and reports no error anywhere.

Verification

Confirm a known tile returns bytes, the media type is correct, and the y-flip lands on real data. Pick a tile whose x/y actually fall inside the dataset's extent — a tile over the ocean will legitimately 204.

import requests

# Fetch a mid-zoom tile over the data extent
resp = requests.get("http://localhost:8080/tiles/12/2138/1450.pbf")
print(resp.status_code, len(resp.content), "bytes")  # 200 18244 bytes
assert resp.status_code == 200 and len(resp.content) > 0
assert resp.headers["Content-Type"] == "application/x-protobuf"
assert resp.headers.get("Content-Encoding") == "gzip"

# TileJSON should advertise the right zoom ceiling
meta = requests.get("http://localhost:8080/tiles.json").json()
print(meta["minzoom"], meta["maxzoom"])                # 0 14
assert meta["maxzoom"] >= 12, "Max zoom too low for street detail"

If tiles load but the map is flipped vertically, the TMS conversion is missing or doubled — verify exactly one (1 << z) - 1 - y is applied along the request path.

Two further assertions catch the failures that a 200 OK hides. The first proves the bytes on the wire really are a gzip member and not MVT you have accidentally inflated (or gzipped twice); the second proves revalidation works, which is the difference between a returning visitor spending forty bytes and forty kilobytes per tile.

import gzip
import requests

url = "http://localhost:8080/v2/tiles/12/2138/1450.pbf"

# stream=True + raw.read() bypasses urllib3's transparent decompression
raw = requests.get(url, stream=True).raw.read()
assert raw[:2] == b"\x1f\x8b", "not gzipped — drop the Content-Encoding header"
print(len(raw), "->", len(gzip.decompress(raw)), "bytes of MVT")   # 18244 -> 61190 bytes of MVT

# A conditional request must come back empty
etag = requests.get(url).headers["ETag"]
again = requests.get(url, headers={"If-None-Match": etag})
print(again.status_code, len(again.content))                       # 304 0
assert again.status_code == 304 and not again.content

# A tile far outside the dataset extent must be a clean 204, never a 500
empty = requests.get("http://localhost:8080/v2/tiles/12/1/1.pbf")
assert empty.status_code == 204 and not empty.content

A gzip.BadGzipFile from the decompress call means the archive stores uncompressed MVT — some pipelines do — and the Content-Encoding: gzip header is lying to the browser. Drop the header rather than compressing on the fly; the tiles are already small.

Edge Cases & Debugging

Frequently Asked Questions

How many tiles per second can one worker realistically serve? With the index in place and the archive resident in the page cache, a lookup and response cost roughly 0.1–0.3 ms, which puts a single uvicorn worker in the low thousands of tiles per second — the framework overhead dominates, not SQLite. Four workers on four cores scale close to linearly because each has its own read-only handle and there is no write lock to contend for. Beyond that, add an edge cache rather than more workers: tiles are deterministic for a given build, so the hit rate approaches 100% and the origin only ever sees cold tiles.

Can one app serve several MBTiles archives? Yes, and it is the main reason to run a process at all. Add a {tileset} path segment, keep a dict of open connections keyed by name, and populate it at startup from a directory scan so the set of valid names is fixed and known. Validate the incoming name against that dict rather than joining it onto a path — a raw path join is a directory-traversal hole that will happily open /etc/passwd as a database. Each archive costs one file handle per worker thread plus its share of the page cache, so a few dozen tilesets are unremarkable and a few thousand are not.

Should I use aiosqlite and an async handler instead? Usually not. FastAPI runs a plain def handler in a thread pool, so a blocking SQLite read never stalls the event loop, and aiosqlite only moves the same blocking call to a different thread while adding a layer of futures. The async version earns its place when the tile path also does network I/O — an authorization lookup against Redis, or a fetch from object storage when the local archive misses — because then the handler is genuinely waiting rather than working.

How do I add authentication without destroying cacheability? Decide first whether the tiles differ per user or only the permission to fetch them. If everyone entitled to the layer sees identical bytes, keep the tiles publicly cacheable and gate access with a short-lived signed URL, so the edge can still serve them and only the signature check is dynamic. If the tiles themselves differ — per-tenant filtering, redacted parcels — then edge caching is off the table for that route: mark it Cache-Control: private, no-store and add Vary: Authorization, and expect the throughput ceiling of the previous answer to be the real one.

How do I publish a rebuilt tileset without downtime? Write the new archive under a versioned name (parcels.2026-08-02.mbtiles), open a fresh connection to it, then swap a module-level reference so subsequent requests use it and in-flight ones finish against the old handle. Close the old connections a few seconds later and delete the file afterwards. Overwriting in place is the failure mode: with immutable=1 set, SQLite is entitled to serve stale mapped pages, and without it you get torn reads mid-request.

Do I ever need to decompress the tiles server-side? Not for pass-through serving — the blob leaves SQLite and enters the socket untouched, which is what makes the endpoint cheap. You only need to inflate, decode and re-encode when you are editing the tile itself: merging two tilesets into one response, or dropping features a user may not see. That costs one protobuf parse and re-serialize per tile, typically a few milliseconds, and it wipes out the ETag reuse described above. Where you can, build separate tilesets per audience at tiling time and keep the request path a straight byte copy.