PMTiles and MBTiles Explained: Tile Archives Without a Server
Problem statement
A tile pyramid is millions of small files. For a city-sized extent, zooms 8 to 16 is 870 tiles; a country at zoom 14 is millions; a full world pyramid to zoom 16 is 5,726,623,061.
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
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, payload in tiles.items(): # payload already gzipped MVT
writer.write_tile(zxy_to_tileid(tile.z, tile.x, tile.y), payload)
writer.finalize({
"tile_type": TileType.MVT,
"tile_compression": Compression.GZIP,
"min_zoom": 10, "max_zoom": 16,
"min_lon_e7": int(-2.35 * 1e7), "min_lat_e7": int(53.43 * 1e7),
"max_lon_e7": int(-2.18 * 1e7), "max_lat_e7": int(53.53 * 1e7),
"center_zoom": 13,
"center_lon_e7": int(-2.24 * 1e7), "center_lat_e7": int(53.48 * 1e7),
}, {"name": "buildings", "attribution": "Β© OpenStreetMap contributors"})
Upload the result to any static host that supports range requests, point a MapLibre style at it, and there is no server.
Step-by-step solution
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
tile_id = zxy_to_tileid(z, x, y)
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.
seen = {}
for tile, payload in tiles.items():
digest = hashlib.sha256(payload).hexdigest()
if digest in seen:
duplicates += 1
continue
seen[digest] = payload
On a dataset with large empty areas this routinely removes most of the archive.
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.
Code examples
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")
return path
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
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(zxy_to_tileid(tile.z, tile.x, tile.y))
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}")
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.
Explanation
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.
Why deduplication matters more than compression
An archive covering a coastline has thousands of identical empty tiles over water. Compressing each one separately still stores thousands of copies.
Both formats let several tile ids point at one blob, so duplicates cost a directory entry rather than a tile. On sparse data that is a much larger saving than any compression setting.
The hash-based approach is simple and effective: compress the tile, hash the bytes, and store only the first occurrence.
Edge cases or notes
- 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 β often most of a sparse archive.
- 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.