PMTiles vs MBTiles for Cloud Tile Serving
Use MBTiles when individual tiles are updated between builds, and PMTiles when the tileset is rebuilt as a unit and read directly by clients. That is the whole decision, and it follows from one structural difference: MBTiles stores its index as a SQLite B-tree, which requires a process to traverse, while PMTiles stores its index as a shallow directory a client can fetch once and then resolve arithmetically. Everything else — the cost model, the deployment shape, the caching story — is downstream of that. This page is the format-level comparison behind PMTiles and cloud-native tile archives.
Quick Reference
| Question | MBTiles | PMTiles | Primary Use Case |
|---|---|---|---|
| How is a tile located? | SQLite B-tree traversal | Hilbert index → directory → byte offset | Archive reads without a server need the second |
| Who can read it? | A process with a SQLite engine | Any HTTP client that speaks Range: |
Browser-direct delivery needs the second |
| Single-tile update | Transactional UPDATE |
Rewrite the archive | Frequently-mutating tilesets need the first |
| Deployment | A running server | An object plus a CDN | Unpredictable traffic favours the second |
| Interchange support | Universal across tooling | Growing, converter-based | Pipeline intermediates favour the first |
Why the Index Structure Decides Everything
SQLite is a superb embedded database and a poor remote one. Its B-tree index is a graph of pages that reference other pages by offset, so locating a row means reading a page, learning where to look next, reading that page, and repeating. On local disk each hop is microseconds. Over HTTP each hop is a dependent round trip — you cannot issue the second request until the first has answered — so a tile lookup that takes 20 microseconds locally takes several hundred milliseconds remotely, and the hops cannot be parallelised because each one’s address comes from the previous one’s contents.
PMTiles removes the dependency chain. Tiles are laid out in Hilbert order, so a client that knows the tile coordinate computes its index locally, with no lookup at all. The directory that maps index to byte offset is a flat, sorted structure fetched once and cached for the session. After that first fetch, every tile request is a single independent range read that can be issued in parallel with all the others.
There is a second, quieter consequence. Because a PMTiles archive never changes, every byte of it is cacheable forever, and a CDN edge that has served one reader’s tiles serves the next reader’s for free. An MBTiles-backed server can cache its responses, but the cache is per-server and must be re-warmed on every deploy, restart, or scale-out event.
Converting an Archive and Proving It Is Identical
Conversion is repackaging, not re-encoding: the same compressed tile payloads move into a new container in a new order, with a new index over them. That makes verification straightforward — sample tiles from both containers and compare bytes.
# Requires: pmtiles>=3.3, Python 3.10+; the `pmtiles` CLI 1.x on PATH
from __future__ import annotations
import random
import sqlite3
import subprocess
from pathlib import Path
from pmtiles.reader import MmapSource, Reader
def convert(mbtiles: Path, archive: Path) -> Path:
"""Repackage MBTiles into a PMTiles archive (tile bytes unchanged)."""
try:
subprocess.run(
["pmtiles", "convert", str(mbtiles), str(archive)],
check=True, capture_output=True, text=True, timeout=3600,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"pmtiles convert failed: {exc.stderr.strip()[:300]}") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("pmtiles convert exceeded its one-hour budget") from exc
return archive
def verify_sample(mbtiles: Path, archive: Path, *, sample: int = 500, seed: int = 7) -> int:
"""Compare tile bytes across a random sample. Returns the number checked."""
conn = sqlite3.connect(f"file:{mbtiles}?mode=ro", uri=True)
try:
rows = conn.execute(
"SELECT zoom_level, tile_column, tile_row, tile_data FROM tiles"
).fetchall()
finally:
conn.close()
if not rows:
raise ValueError(f"{mbtiles} contains no tiles")
rng = random.Random(seed)
picked = rng.sample(rows, min(sample, len(rows)))
with open(archive, "rb") as handle:
reader = Reader(MmapSource(handle))
for zoom, column, row, expected in picked:
# MBTiles stores rows in TMS order; PMTiles addresses tiles in XYZ.
xyz_row = (1 << zoom) - 1 - row
actual = reader.get(zoom, column, xyz_row)
if actual != expected:
raise AssertionError(
f"tile {zoom}/{column}/{xyz_row} differs "
f"({len(expected)} vs {len(actual or b'')} bytes)"
)
return len(picked)
Validation
Run the sample comparison at several zooms rather than uniformly at random, because the failure that actually happens is a row-order mistake, and it only shows at zooms where the flip changes the value:
# Expect: identical byte counts, and a directory small enough to arrive in one fetch
pmtiles show archive.pmtiles
# tile type: mvt · min zoom: 4 · max zoom: 14
# root directory: 12,284 bytes · leaf directories: 3,910 · tile entries: 2,104,553
curl -s -I -H 'Range: bytes=0-16383' https://cdn.example.com/archive.pmtiles \
| grep -Ei 'accept-ranges|content-range|access-control-expose'
# Expect: HTTP/2 206, Content-Range present, Access-Control-Expose-Headers listing Content-Range
Healthy ranges: a root directory under about 16 KB means the first fetch covers it; tile entry counts should match the source’s row count exactly, and any discrepancy means tiles were dropped rather than reordered.
Edge Cases and Caveats
TMS versus XYZ row order. MBTiles numbers rows from the bottom, most tile clients number them from the top, and the flip is (1 << z) - 1 - row. Converters handle it, but hand-written comparison code frequently does not — which produces a map that renders correctly at zoom 0 and is vertically mirrored everywhere else. The verification above applies the flip explicitly for exactly this reason.
Metadata that does not survive the trip. MBTiles carries a metadata table with arbitrary key-value pairs; PMTiles carries a structured header plus a JSON blob. Custom keys your application depends on — an attribution string, a layer manifest, a build timestamp — may be dropped or relocated. Enumerate them before converting and assert their presence afterwards, in the spirit of preserving metadata during conversion.
Archives that grow past a comfortable single object. Both formats handle multi-gigabyte tilesets, but a PMTiles archive is rebuilt whole, so a 40 GB archive means a 40 GB rewrite for any change. Past roughly 20 GB, split by region and accept a few extra directory fetches, as the parent guide describes.
Frequently Asked Questions
Can a browser read an MBTiles file directly from object storage?
Not practically. MBTiles is a SQLite database, and finding a tile means walking B-tree index pages whose locations are only discoverable by reading other pages. A browser would need a SQLite engine and many dependent round trips per tile, each one blocking on the last. That is why MBTiles is served by a process that opens the file locally, and why PMTiles replaced the B-tree with a directory a client can cache and index arithmetically.
Is MBTiles obsolete now that PMTiles exists?
No. MBTiles remains the better container whenever tiles change individually rather than in whole rebuilds, because SQLite gives you transactional single-tile updates that a byte-offset archive cannot. It is also the interchange format most tooling still speaks, so it is often the intermediate you build before packing an archive. What has changed is that it is no longer the right thing to put in front of readers.
Does converting MBTiles to PMTiles change the tiles themselves?
No. Conversion is a repackaging: the same compressed tile payloads are copied into a new container in Hilbert order with a new index built over them. Tile bytes are unchanged, which is why a byte-for-byte checksum comparison over a sample is a valid verification. What changes is the ordering on disk and the structure used to find a tile.
Related
- PMTiles and Cloud-Native Tile Archives — parent guide: archive anatomy, cost model, and when a tile archive beats a server
- Serving Vector Tiles from Object Storage with Range Requests — the headers and CDN settings the client side depends on
- Converting GeoParquet to PMTiles with Tippecanoe — building an archive from analytical storage rather than converting one
- Space-Filling Curves for Spatial Partitioning — the Hilbert ordering that makes the archive’s index arithmetic work
← Back to PMTiles and Cloud-Native Tile Archives