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

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