Serving MBTiles with Python
When you need a running endpoint — for access control, dynamic layer selection, or fitting into an existing API — MBTiles 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. 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 table maps zoom_level / tile_column / tile_row to a blob of MVT (or PNG) bytes. Serving it is just a parameterized query per tile request. The single detail that breaks every first attempt is the TMS vs XYZ row flip: MBTiles stores tiles in TMS scheme (row 0 at the bottom), while web clients request 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.
A static PMTiles file (see Generating PMTiles from GeoParquet) avoids running any server at all; reach for MBTiles serving only when you genuinely need server-side logic.
Prerequisites
fastapi>=0.110anduvicorn>=0.29- Python's built-in
sqlite3(no install) - An existing
.mbtilesfile (built with tippecanoe)
pip install "fastapi>=0.110" "uvicorn>=0.29"
Step-by-Step Implementation
1. Open the MBTiles and read its metadata.
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
2. Serve tiles, flipping the y axis from XYZ to TMS.
from fastapi import FastAPI, Response, HTTPException
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
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:
raise HTTPException(status_code=204) # empty tile, not an error
headers = {"Access-Control-Allow-Origin": "*"}
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)
3. Run it.
# uvicorn tile_server:app --port 8080
# Tiles available at http://localhost:8080/tiles/{z}/{x}/{y}.pbf
4. Point MapLibre at the endpoint.
# map.addSource("parcels", {
# type: "vector",
# tiles: ["http://localhost:8080/tiles/{z}/{x}/{y}.pbf"],
# minzoom: 0, maxzoom: 14,
# });
Verification
Confirm a known tile returns bytes and the y-flip is correct.
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"
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.
Edge Cases & Debugging
- Map mirrored vertically. The XYZ↔TMS y-flip is wrong; apply it exactly once.
- Garbled vector tiles. Tippecanoe gzips MVT blobs; send
Content-Encoding: gzip(above) or decompress before serving. - CORS errors in the browser. Add
Access-Control-Allow-Originon tile responses. database is locked. Open read-only (mode=ro) andcheck_same_thread=Falsefor concurrent reads.- 204 floods the console. Empty tiles over no-data areas are normal; clients ignore 204/empty responses.
- Slow under load. SQLite reads are fast, but add an in-process LRU cache for hot low-zoom tiles, or switch to static PMTiles if no server logic is needed.