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.

Dependent B-tree hops versus one independent range read The upper timeline shows a remote MBTiles lookup: the client reads the SQLite header, then a root index page, then an interior page, then a leaf page, and only then the tile payload. Each request must wait for the previous one because its address comes from the previous response, so the latencies add. The lower timeline shows PMTiles: the client computes the Hilbert index locally with no request at all, consults a directory it already cached, and issues one range read for the tile. Several tiles can be fetched in parallel because none of their addresses depend on each other. MBTiles read directly over HTTP — every hop waits for the last SQLite header RTT 1 root index page RTT 2 interior page RTT 3 leaf page RTT 4 tile bytes RTT 5 latencies add: five sequential round trips per tile, and they cannot be overlapped PMTiles — index computed locally, one read per tile Hilbert index of z/x/y arithmetic — no request cached directory fetched once per session tile bytes RTT 1 tile bytes RTT 1 tile bytes RTT 1 independent addresses: the three tile reads are issued together and complete in one round trip This is why MBTiles needs a process and PMTiles does not — the dependency chain, not the file size. A tile server hides the chain by keeping the file open locally, where each hop costs microseconds instead of milliseconds.

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.

What has to exist in production for each format The MBTiles path requires a load balancer, two or more tile server instances each holding an open SQLite file and its own response cache, and a health-check and deploy process around them. The PMTiles path requires a content delivery network edge and one immutable object in a bucket. The second column has no process to keep alive, so a cache warmed by one reader serves every subsequent reader. MBTiles in production load balancer + health checks tile server 1 open SQLite file own response cache tile server 2 open SQLite file cold cache after deploy the .mbtiles file on attached storage four things to keep alive, patch, and scale PMTiles in production CDN edge — shared across all readers one immutable object cacheable forever, no invalidation step nothing else — the bucket answers range requests one thing to publish, nothing to keep alive

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.

python
# 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:

bash
# 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.

Why a converted tileset comes out vertically mirrored Two four-by-four grids at zoom level two. In the TMS scheme used by MBTiles, row zero is the bottom row and numbering increases upward. In the XYZ scheme used by most tile clients, row zero is the top row and numbering increases downward. The conversion between them subtracts the row index from two to the power of the zoom minus one. A tileset copied without applying the flip renders correctly only where the two schemes coincide, and is mirrored everywhere else. TMS (MBTiles) — row 0 at the bottom row 3 row 2 row 1 row 0 south edge of the map flip (1 << z) - 1 - row XYZ (most clients) — row 0 at the top row 0 row 1 row 2 row 3 north edge of the map

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.


← Back to PMTiles and Cloud-Native Tile Archives