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.
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.
Prerequisites
fastapi>=0.110— the ASGI app and routinguvicorn[standard]>=0.29— the ASGI server; thestandardextra pullshttptoolsanduvloop, which is where the throughput ceiling of this design actually sitsrequests>=2.31— only for the verification step- Python's built-in
sqlite3(no install needed); any CPython 3.9+ ships an SQLite new enough for theimmutable=1URI flag used below - The
sqlite3command-line tool, if you need to add a missing index to an existing archive — it is not part of the Python package - An existing
.mbtilesfile built withtippecanoe(see Generating PMTiles from GeoParquet for the build — the same MBTiles is the input here, before the PMTiles conversion step)
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)
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
- Map mirrored vertically. The XYZ↔TMS y-flip is wrong; apply
(1 << z) - 1 - yexactly once, in the query only. - Garbled or blank vector tiles. Tippecanoe stores MVT blobs gzip-compressed; send
Content-Encoding: gzip(above) so the browser inflates them, or decompress withgzip.decompress()before serving. - CORS errors in the browser. Cross-origin tile fetches need
Access-Control-Allow-Originon every tile and the TileJSON response — a missing header ontiles.jsonsilently disables the whole source. database is locked. Open withmode=roandcheck_same_thread=False; a read-only handle never contends for the write lock.- 204 floods the network tab. Empty tiles over no-data areas are normal and clients ignore them; do not "fix" this by returning a 200 with an empty body, which MapLibre treats as a corrupt tile.
source-layernot rendering. The MapLibre layer'ssource-layermust equal the layer name tippecanoe wrote (-l parcels), not the MBTiles filename or the source id.- Slow under concurrent load. SQLite reads are fast, but wrap the handler with an
functools.lru_cacheon(z, x, y)for hot low-zoom tiles, or swapsqlite3foraiosqliteand an async handler so one slow read doesn't block the event loop. If no server logic is needed, a static PMTiles archive removes the bottleneck entirely. - Every single request takes hundreds of milliseconds. The
tile_indexunique index is missing, so each lookup scans the whole table. Confirm withEXPLAIN QUERY PLANas in step 2 and create the index once, offline. - Intermittent 500s over empty areas.
raise HTTPException(status_code=204)renders a JSON body, andh11refuses to send a body with a 204. ReturnResponse(status_code=204)instead. - Tiles arrive double-compressed. Adding Starlette's
GZipMiddlewarein front of an archive that already stores gzipped MVT compresses the bytes a second time; the browser inflates once and hands MapLibre a gzip member it cannot parse. Exclude the tile routes from the middleware. - Workers keep serving the old pyramid after a rebuild. An open SQLite handle pins the deleted inode, so
os.replaceswaps the file without changing what a running worker reads. Publish under a new filename and reopen, or restart the workers — and never write into an archive opened withimmutable=1, which corrupts silently rather than erroring. - The layer vanishes when you zoom past a certain level. MBTiles holds nothing above the build's
maxzoom, so requests there legitimately 204. Advertisemaxzoomin the TileJSON and MapLibre will overzoom the deepest stored tiles instead of asking for tiles that do not exist. unable to open database fileonly in the container. The path is relative to the process working directory, not the module; resolve it withpathlib.Path(__file__).parentbefore connecting, and confirm the archive is actually copied into the image rather than mounted from a build-stage layer.
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.