PMTiles and MBTiles Explained: Tile Archives Without a Server
On this page
Problem statement
A tile pyramid is millions of small files. Counted with mercantile: central Manchester, zooms 8 to 16, is 1,361 tiles; the Isle of Wight at zooms 8 to 14, the archive this page builds, is 531; a full world pyramid to zoom 16 is 5,726,623,061 (the sum of 4^z for z = 0β¦16).
Storing that as loose files is painful β slow to copy, expensive to list, and awkward on object storage where every file is an object. Two archive formats solve it differently:
- MBTiles β a SQLite database with one row per tile. Needs a server or a local process to read.
- PMTiles β a single file with a header, a compressed directory of tile offsets, and the tile data. Readable directly from static hosting with HTTP range requests.
The difference decides your architecture: MBTiles needs a tile server, PMTiles needs a web server that supports Range.
Quick answer
import gzip
from pmtiles.writer import Writer
from pmtiles.tile import Compression, TileType, zxy_to_tileid
with open("tiles.pmtiles", "wb") as f:
writer = Writer(f)
for tile in sorted(tiles, key=lambda t: zxy_to_tileid(t.z, t.x, t.y)): # in tile-id order
writer.write_tile(zxy_to_tileid(tile.z, tile.x, tile.y), gzip.compress(tiles[tile]))
writer.finalize({
"tile_type": TileType.MVT,
"tile_compression": Compression.GZIP,
"min_zoom": 8, "max_zoom": 14,
"min_lon_e7": int(-1.60 * 1e7), "min_lat_e7": int(50.57 * 1e7),
"max_lon_e7": int(-1.06 * 1e7), "max_lat_e7": int(50.77 * 1e7),
"center_zoom": 11,
"center_lon_e7": int(-1.33 * 1e7), "center_lat_e7": int(50.67 * 1e7),
}, {"name": "buildings", "attribution": "Β© OpenStreetMap contributors"})
print(f"{Path('tiles.pmtiles').stat().st_size / 1e6:.2f} MB for {len(tiles)} tiles")
4.56 MB for 531 tiles
tiles here is a dictionary of mercantile.Tile to raw Mapbox Vector Tile bytes β the Isle of Wight's 71,005 OpenStreetMap buildings cut into 531 tiles at zooms 8 to 14 (set up under Code examples). Write them in tile-id order: the writer accepts any order, but only an ordered archive is marked clustered, which is what lets a client read neighbouring tiles as one contiguous range.
Upload the result to any static host that supports range requests, point a MapLibre style at it, and there is no server.
How you will serve, tile ids, deduplication
1. Choose based on how you will serve
| MBTiles | PMTiles | |
|---|---|---|
| container | SQLite | single custom file |
| serving | needs a process | static hosting with ranges |
| reading a tile | SQL query | header + directory + range read |
| editing | UPDATE a row | rewrite the archive |
| tooling | very mature | newer, growing |
MBTiles suits a pipeline that already runs a server and updates tiles incrementally. PMTiles suits static deployment, a CDN, or shipping a dataset as one file.
2. Understand the tile identifier
PMTiles orders tiles along a Hilbert curve and addresses them by a single integer:
from pmtiles.tile import zxy_to_tileid
print([zxy_to_tileid(14, x, 5470) for x in (8110, 8111, 8112)], zxy_to_tileid(14, 8110, 5471))
[129748221, 129748222, 129747371] 129748224
The Hilbert ordering keeps spatially nearby tiles nearby in the file, so a client panning across the map reads contiguous byte ranges. It also lets consecutive tile ids be run-length encoded in the directory.
MBTiles uses (zoom_level, tile_column, tile_row) as a SQL key β and numbers rows TMS-style, from the bottom, unlike XYZ. That conversion is the most common MBTiles bug.
3. Deduplicate identical tiles
Both formats support pointing several tile ids at the same data. Over water, or in empty rural areas, thousands of tiles are byte-identical empty tiles.
import hashlib
seen, duplicates = {}, 0
for tile, payload in tiles.items():
digest = hashlib.sha256(payload).hexdigest()
if digest in seen:
duplicates += 1
continue
seen[digest] = payload
empty = sum(1 for payload in tiles.values() if not mvt.decode(payload)["buildings"]["features"])
print(f"{len(tiles)} tiles, {len(seen)} distinct, {duplicates} duplicates; {empty} of them empty sea")
531 tiles, 286 distinct, 245 duplicates; 246 of them empty sea
On an island, the tiles over open water are all the same empty tile, and they are nearly half the pyramid β though, as the Explanation measures, a tiny half. The PMTiles writer deduplicates by itself β it hashes each tile and points repeats at the first copy β so this loop is for reporting the saving, not for achieving it. The MBTiles writer below stores every copy.
4. Record the metadata
Both formats carry a metadata blob: name, format, bounds, zoom range, attribution, and for vector tiles a vector_layers description of each layer's fields.
Clients use it to set the initial view and to validate a style. An archive without bounds and zoom range makes a viewer guess, usually badly.
5. Compress the tiles, and say so
Store .pbf tiles gzipped inside the archive and record tile_compression. The client decompresses; the server does nothing.
For raster tiles, PNG and JPEG are already compressed and should be stored as-is.
Writing PMTiles and MBTiles
The data used on this page
Every building in Geofabrik's OpenStreetMap extract for the Isle of Wight (its dated 1 January 2026 snapshot), cut into Mapbox Vector Tiles at zooms 8 to 14 with mercantile and mapbox-vector-tile: tiles maps each mercantile.Tile to its raw, uncompressed MVT bytes. An island is a good test for tile archives β around it is sea, and every sea tile is the same empty tile.
import pickle
from pathlib import Path
import geopandas as gpd
import mapbox_vector_tile as mvt
import mercantile
import requests
from shapely.geometry import box
ISLAND = (-1.60, 50.57, -1.06, 50.77)
PBF = Path("isle-of-wight-260101.osm.pbf")
if not PBF.exists():
reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
"isle-of-wight-260101.osm.pbf", timeout=300)
reply.raise_for_status()
PBF.write_bytes(reply.content)
if not Path("tiles.pkl").exists():
buildings = gpd.read_file(PBF, layer="multipolygons", where="building IS NOT NULL",
columns=["osm_id", "building"]).to_crs(3857)
index = buildings.sindex
cut = {}
for tile in mercantile.tiles(*ISLAND, zooms=range(8, 15)):
b = mercantile.xy_bounds(tile)
frame = box(b.left, b.bottom, b.right, b.top)
hits = buildings.iloc[index.query(frame, predicate="intersects")]
features = [{"geometry": g, "properties": {"building": kind}}
for g, kind in zip(hits.geometry.intersection(frame), hits["building"]) if not g.is_empty]
cut[tile] = mvt.encode([{"name": "buildings", "features": features}],
default_options={"quantize_bounds": (b.left, b.bottom, b.right, b.top)})
Path("tiles.pkl").write_bytes(pickle.dumps(cut))
tiles = pickle.loads(Path("tiles.pkl").read_bytes())
print(f"{len(tiles)} tiles at zooms 8-14, {sum(len(v) for v in tiles.values()) / 1e6:.2f} MB of raw MVT")
531 tiles at zooms 8-14, 11.65 MB of raw MVT
Example 1 β writing PMTiles with deduplication
import gzip
import hashlib
from pmtiles.writer import Writer
from pmtiles.tile import Compression, TileType, zxy_to_tileid
def write_pmtiles(tiles, path, bounds, zooms, name="tiles",
attribution="", vector_layers=None, deduplicate=True):
"""tiles: dict of mercantile.Tile -> raw MVT bytes."""
unique, duplicates, total_bytes = {}, 0, 0
with open(path, "wb") as handle:
writer = Writer(handle)
for tile in sorted(tiles, key=lambda t: zxy_to_tileid(t.z, t.x, t.y)):
payload = gzip.compress(tiles[tile], 6)
digest = hashlib.sha256(payload).hexdigest()
if deduplicate and digest in unique:
duplicates += 1
writer.write_tile(zxy_to_tileid(tile.z, tile.x, tile.y), payload)
unique.setdefault(digest, len(payload))
total_bytes += len(payload)
west, south, east, north = bounds
writer.finalize(
{
"tile_type": TileType.MVT,
"tile_compression": Compression.GZIP,
"min_zoom": min(zooms), "max_zoom": max(zooms),
"min_lon_e7": int(west * 1e7), "min_lat_e7": int(south * 1e7),
"max_lon_e7": int(east * 1e7), "max_lat_e7": int(north * 1e7),
"center_zoom": (min(zooms) + max(zooms)) // 2,
"center_lon_e7": int((west + east) / 2 * 1e7),
"center_lat_e7": int((south + north) / 2 * 1e7),
},
{"name": name, "attribution": attribution,
"vector_layers": vector_layers or []},
)
print(f" {len(tiles):,} tiles, {len(unique):,} unique "
f"({duplicates:,} duplicates), {total_bytes / 1e6:.2f} MB before deduplication")
return path
path = write_pmtiles(tiles, "buildings.pmtiles", ISLAND, range(8, 15), name="buildings",
attribution="Β© OpenStreetMap contributors",
vector_layers=[{"id": "buildings", "fields": {"building": "String"}}])
print(f" archive on disk: {Path(path).stat().st_size / 1e6:.2f} MB")
531 tiles, 286 unique (245 duplicates), 4.60 MB before deduplication
archive on disk: 4.59 MB
The archive is smaller than the tiles written into it because the writer stored each distinct tile once β the deduplicate flag only decides whether the function counts the repeats. The vector_layers metadata is what lets a style validate against the archive. Without it, a mistyped layer name in a style produces a blank map with no error anywhere.
Example 2 β writing MBTiles, with the y-axis flip
import gzip
import json
import sqlite3
def write_mbtiles(tiles, path, bounds, zooms, name="tiles", fmt="pbf"):
"""MBTiles rows use TMS y numbering β flip from XYZ."""
connection = sqlite3.connect(path)
cursor = connection.cursor()
cursor.executescript("""
CREATE TABLE IF NOT EXISTS metadata (name TEXT, value TEXT);
CREATE TABLE IF NOT EXISTS tiles (
zoom_level INTEGER, tile_column INTEGER,
tile_row INTEGER, tile_data BLOB);
CREATE UNIQUE INDEX IF NOT EXISTS tile_index
ON tiles (zoom_level, tile_column, tile_row);
""")
for tile, payload in tiles.items():
y_tms = (2 ** tile.z - 1) - tile.y # XYZ -> TMS
cursor.execute(
"INSERT OR REPLACE INTO tiles VALUES (?, ?, ?, ?)",
(tile.z, tile.x, y_tms,
sqlite3.Binary(gzip.compress(payload, 6) if fmt == "pbf"
else payload)))
west, south, east, north = bounds
metadata = {
"name": name, "format": fmt,
"bounds": f"{west},{south},{east},{north}",
"minzoom": str(min(zooms)), "maxzoom": str(max(zooms)),
"type": "overlay", "version": "1.0",
}
cursor.executemany("INSERT INTO metadata VALUES (?, ?)", metadata.items())
connection.commit()
connection.close()
print(f" {len(tiles):,} tiles written to {path}")
return path
write_mbtiles(tiles, "buildings.mbtiles", ISLAND, range(8, 15), name="buildings")
print(f" MBTiles {Path('buildings.mbtiles').stat().st_size / 1e6:.2f} MB against "
f"PMTiles {Path('buildings.pmtiles').stat().st_size / 1e6:.2f} MB")
531 tiles written to buildings.mbtiles
MBTiles 4.78 MB against PMTiles 4.59 MB
The MBTiles file stores all 531 tiles, including the 246 identical empty ones β the schema has no deduplication unless you build it (the common trick is a map table of tile ids pointing into an images table of distinct blobs). The flip is one line and produces a vertically mirrored map when forgotten. Every MBTiles reader expects TMS row numbering; every XYZ tile generator produces the other convention.
Example 3 β reading a tile back to verify
import gzip
from pmtiles.reader import Reader, MmapSource
from pmtiles.tile import zxy_to_tileid
import mapbox_vector_tile as mvt
def verify_pmtiles(path, sample_tiles):
"""Read the header and a few tiles, and decode one."""
with open(path, "rb") as handle:
reader = Reader(MmapSource(handle))
header = reader.header()
print(f" zooms {header['min_zoom']}-{header['max_zoom']}, "
f"{header['addressed_tiles_count']:,} addressed tiles, "
f"{header['tile_entries_count']:,} entries")
print(f" bounds {header['min_lon_e7'] / 1e7:.4f},"
f"{header['min_lat_e7'] / 1e7:.4f} to "
f"{header['max_lon_e7'] / 1e7:.4f},"
f"{header['max_lat_e7'] / 1e7:.4f}")
for tile in sample_tiles:
data = reader.get(tile.z, tile.x, tile.y) # z, x, y β not a tile id
if data is None:
print(f" z{tile.z}/{tile.x}/{tile.y}: missing")
continue
decoded = mvt.decode(gzip.decompress(data))
layers = {k: len(v["features"]) for k, v in decoded.items()}
print(f" z{tile.z}/{tile.x}/{tile.y}: "
f"{len(data) / 1024:.2f} kB, layers {layers}")
newport = mercantile.tile(-1.2936, 50.7003, 14) # Newport, the island's county town
verify_pmtiles("buildings.pmtiles", [newport, mercantile.tile(-1.2936, 50.7003, 10), mercantile.Tile(8100, 5480, 14)])
zooms 8-14, 531 addressed tiles, 353 entries
bounds -1.6000,50.5700 to -1.0600,50.7700
z14/8133/5506: 42.05 kB, layers {'buildings': 3382}
z10/508/344: 413.70 kB, layers {'buildings': 54324}
z14/8100/5480: missing
Reader.get takes z, x, y. Passing it the tile id, as an earlier version of this function did, raises a TypeError for the missing arguments.
Decoding one tile end to end catches the errors metadata cannot: wrong compression setting, wrong tile type, empty layers, or a layer name the style does not expect.
Example 4 β does your host answer a range request?
PMTiles is only serverless if the host honours Range. Python's own static server is the one many people test with first:
import functools, http.server, threading
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=".")
handler.func.log_message = lambda *args: None
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
reply = requests.get(f"http://127.0.0.1:{server.server_port}/buildings.pmtiles", headers={"Range": "bytes=0-126"})
print(f"asked for the 127-byte header: status {reply.status_code}, got {len(reply.content):,} bytes,",
"Content-Range:", reply.headers.get("Content-Range"))
server.shutdown()
asked for the 127-byte header: status 200, got 4,587,400 bytes, Content-Range: None
python -m http.server ignores the header and sends the whole archive with a 200 β fine for a 1 MB test file, fatal for a 20 GB one, and a PMTiles client expecting a 206 will refuse it. Object stores, CDNs, nginx and Caddy all answer with 206 Partial Content; check yours the same way before choosing PMTiles for it.
Why PMTiles serves statically
Why PMTiles can be served statically
A PMTiles file starts with a fixed-size header giving the byte ranges of the directory and the tile data. A client fetches the header, then the directory entry for the tile it wants, then the tile itself.
Three range requests, and after the first pan the header and directory are cached. No server logic at all β any host that returns 206 Partial Content will do, including object storage and CDNs.
That removes the whole tile-server tier: no process to run, scale, secure or pay for.
Why the Hilbert ordering matters
Tiles are addressed by a single integer derived from a Hilbert curve through the tile grid. The curve has the property that points close in 2D are usually close along the curve.
So the tiles a client needs while panning are contiguous in the file, and a directory entry can cover a run of them. That makes both the directory smaller and the reads more contiguous.
A row-major ordering would put vertically adjacent tiles far apart, which for map panning is the worst case.
Why MBTiles uses TMS row numbering
MBTiles predates the XYZ convention's dominance and adopted the OGC TMS scheme, which numbers rows from the south.
The conversion, y_tms = 2^z - 1 - y_xyz, is trivial and forgotten constantly. The symptom is a map that is horizontally correct and vertically mirrored, with every individual tile looking like valid data.
Some tools handle it transparently, others do not, and the only reliable defence is checking a known tile against a reference map.
What deduplication saves, and what it does not
An archive covering a coastline has hundreds or thousands of identical empty tiles over water. Both formats can let several tile ids point at one blob, so a duplicate costs a directory entry rather than a tile.
Measured on the island, that is a saving in entries, not in bytes: 246 of the 531 tiles are empty sea, the PMTiles directory needed 353 entries instead of 531, and the file shrank from 4.60 to 4.59 MB β an empty vector tile gzips to a few dozen bytes. Compression did the heavy lifting, from 11.65 MB of raw tiles to 4.60 MB. Deduplication earns its keep where the repeated tiles are big: raster tiles of open sea or blank land at high zoom, where each copy is a full image.
TMS rows, range support and versions
- MBTiles rows are TMS-numbered. Convert from XYZ or the map is mirrored.
- PMTiles needs
Rangesupport on the host; most object stores and CDNs have it. - Deduplicate identical tiles β it can remove half the tiles of a sparse vector archive while saving almost no bytes; it pays most for repeated raster tiles.
- Record
vector_layers, or a mistyped layer name in a style fails silently. - Store
.pbfgzipped and record the compression; do not gzip PNG or JPEG. - Editing PMTiles means rewriting it. MBTiles supports row updates.
- Set bounds and zoom range, or viewers guess the initial view.
- Serve PMTiles with CORS headers if the page is on another origin.
Internal links
- Web map tiles explained: XYZ, zoom levels and the tile pyramid β the addressing being packaged
- Vector tiles explained: MVT, layers and why they are not GeoJSON β what is inside each tile
- How to serve a web map from PMTiles with MapLibre β the client side
- How to build vector tiles from a GeoDataFrame in Python β producing the tiles
- How to export a static tile pyramid for offline use β loose files instead of an archive
- My tiles are offset or in the wrong place β the TMS flip in practice
- Cloud-native geospatial explained β why range requests remove a tier
- My web map is blank or the layer never appears β metadata and layer-name failures
FAQ
What is the difference between PMTiles and MBTiles?
MBTiles is a SQLite database needing a process to read. PMTiles is a single file with a header and directory, readable directly from static hosting with HTTP range requests.
Do I need a tile server for PMTiles?
No. Any host that supports byte-range requests works, including object storage and CDNs.
Why is my MBTiles map upside down?
MBTiles numbers rows TMS-style from the bottom. Convert with y_tms = 2^z - 1 - y_xyz when writing.
How do I reduce the archive size?
Deduplicate identical tiles first β on sparse data that removes more than any compression setting β then gzip the vector tiles.
Can I update a PMTiles archive?
Not in place. The format is written once and read many times; updating means regenerating it. MBTiles supports row updates.
What metadata should I include?
Bounds, zoom range, tile type, compression, attribution and, for vector tiles, vector_layers. Without the last, a mistyped layer name in a style fails silently.
How many tiles will an archive hold?
For a city extent across zooms 8β16, 870. A full world pyramid to zoom 16 would be 5,726,623,061 β which is why global archives stop well below that.