PMTiles vs MBTiles for read-only edge basemaps

Choosing the container for an on-device basemap looks like a format preference and is actually a decision about how many bytes the reader has to touch to answer one tile request. This page compares the two containers a field gateway realistically ships — MBTiles, a SQLite database with a fixed schema, and PMTiles, a single flat file with a hierarchical directory baked into its head — on a 512 MB ARM gateway running a read-only rootfs with the archive on eMMC.

It belongs to the Core Edge GIS Fundamentals guide and to the offline tile and basemap storage topic, which covers the read path, the hot cache and the coverage policy that both formats sit underneath. Here the question is narrower: given that read path, which container costs less to run, and under which conditions does the answer flip?

Selection rationale: what actually differs

Both formats store the same thing — compressed tile blobs addressed by z/x/y — and both are single files that can be copied, checksummed and swapped atomically. The differences that matter on a constrained device are in the lookup structure and in what the process has to keep resident.

MBTiles resolves a tile through SQLite’s B-tree index on (zoom_level, tile_column, tile_row). The index pages are read through SQLite’s pager, which means the working set is whatever B-tree interior pages the recent queries touched — typically 100–400 KB for a regional archive. SQLite itself costs about 700 KB of resident memory once loaded, and it is already present on every Linux gateway image, so the marginal cost is close to zero.

PMTiles resolves a tile through its own directory structure: a root directory in the header, and leaf directories fetched on demand. The design target was HTTP range requests against object storage, and that constraint produced something that happens to suit flash extremely well — the directory is a few kilobytes, the entries are delta-encoded and run-length compressed, and a lookup is one or two reads with no query planner in between. The reader is a few hundred lines with no dependency at all.

The practical consequence is that MBTiles wins where SQLite is already a dependency and the deployment wants one storage technology for tiles, the spool and the reference layer; PMTiles wins where the reader has to be minimal, where the archive may also be served over HTTP from the same file, or where a language without a good SQLite binding is doing the reading.

Lookup structures compared for one tile request Two lookup paths. MBTiles walks a SQLite B-tree: a root page, one or two interior pages and a leaf page, each a 4 kilobyte read through the pager, before reaching the tile blob — four reads in the cold case and one when the interior pages are already cached. PMTiles reads a root directory held in the header, which points at a leaf directory, which gives a byte offset and length for a single read of the tile — two reads cold and one warm, with the root directory permanently resident at about 6 kilobytes. Same answer, different number of pages touched MBTiles B-tree root interior pages ×1–2 leaf page tile blob ≈300 KB pager working set PMTiles root directory · resident leaf directory · on demand tile blob ≈6 KB root, delta-encoded entries Cold: MBTiles 3–4 page reads, PMTiles 2. Warm: both one read for the blob itself. On eMMC that difference is under a millisecond; on a worn SD card at 3 ms a read it is visible during a pan.
The gap is entirely in the cold path. Once a viewport has been panned across once, both formats converge on a single read per tile.

A reader that works against either container

The pragmatic answer for a fleet that has not committed yet is to keep the archive behind an interface with two implementations, so the container becomes a provisioning decision rather than a code decision. The PMTiles side is short enough to write by hand:

# pmtiles_reader.py — minimal read-only PMTiles v3 lookup.
# No third-party dependencies; one file handle, no threads, no writes.
# Call from a single reader task, or give each task its own instance.
import struct
import gzip
from typing import NamedTuple

HEADER_LEN = 127


class Entry(NamedTuple):
    tile_id: int
    offset: int
    length: int
    run: int


def zxy_to_tile_id(z: int, x: int, y: int) -> int:
    """Hilbert-order tile id — the ordering PMTiles stores entries in."""
    acc = 0
    for t in range(z):
        acc += (1 << t) * (1 << t)
    n = 1 << z
    rx = ry = 0
    d = 0
    s = n >> 1
    while s > 0:
        rx = 1 if (x & s) > 0 else 0
        ry = 1 if (y & s) > 0 else 0
        d += s * s * ((3 * rx) ^ ry)
        # rotate
        if ry == 0:
            if rx == 1:
                x = s - 1 - x
                y = s - 1 - y
            x, y = y, x
        s >>= 1
    return acc + d


class PMTiles:
    def __init__(self, path: str):
        self._fh = open(path, "rb", buffering=0)
        head = self._fh.read(HEADER_LEN)
        if head[:7] != b"PMTiles":
            raise ValueError("not a PMTiles archive")
        # v3 header: offsets/lengths are little-endian uint64 at fixed positions.
        (self._root_off, self._root_len,
         self._meta_off, self._meta_len,
         self._leaf_off, self._leaf_len,
         self._data_off, self._data_len) = struct.unpack_from("<8Q", head, 8)
        self._internal_compression = head[97]
        self._root = self._read_dir(self._root_off, self._root_len)

    def _read_dir(self, off: int, length: int) -> list[Entry]:
        self._fh.seek(off)
        raw = self._fh.read(length)
        if self._internal_compression == 2:          # gzip
            raw = gzip.decompress(raw)
        return _decode_directory(raw)

    def get(self, z: int, x: int, y: int) -> bytes | None:
        tid = zxy_to_tile_id(z, x, y)
        entries = self._root
        for _ in range(4):                            # depth is 1–2 in practice
            e = _find(entries, tid)
            if e is None:
                return None
            if e.run == 0:                            # points at a leaf directory
                entries = self._read_dir(self._leaf_off + e.offset, e.length)
                continue
            self._fh.seek(self._data_off + e.offset)
            return self._fh.read(e.length)
        return None


def _find(entries: list[Entry], tid: int) -> Entry | None:
    """Binary search honouring run-length entries."""
    lo, hi = 0, len(entries) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        e = entries[mid]
        if tid < e.tile_id:
            hi = mid - 1
        elif tid >= e.tile_id + max(e.run, 1):
            lo = mid + 1
        else:
            return e
    # A run-length entry can still cover tid via the preceding entry.
    if hi >= 0 and entries[hi].run == 0:
        return entries[hi]
    return None

The _decode_directory helper (omitted for length) unpacks four varint arrays — tile ids as deltas, run lengths, entry lengths, and offsets with a zero meaning “immediately after the previous entry”. That encoding is why a directory covering tens of thousands of tiles fits in a few kilobytes, and it is the single design decision that makes the format viable on a device that cannot hold a full index.

Constraint validation

Constraint Expected impact How each container behaves
RAM An index proportional to tile count would not fit MBTiles: pager working set ~300 KB, bounded by cache_size. PMTiles: root directory ~6 KB resident, leaves read on demand
CPU / latency Per-tile lookup competes with rendering MBTiles: SQL parse avoided via prepared statement, ~40 µs warm. PMTiles: binary search over a decoded array, ~8 µs warm
Flash reads Random reads dominate the frame budget MBTiles: 1 read warm, 3–4 cold. PMTiles: 1 warm, 2 cold
Read-only rootfs A writable open would fail at runtime MBTiles: needs immutable=1 to avoid WAL/shm creation. PMTiles: opened rb, nothing to configure
Update mechanism Archives arrive as whole files Identical for both: write beside, fsync, rename, remap
Tooling on the device Debugging without a laptop MBTiles: sqlite3 is usually already present. PMTiles: needs a purpose-built dump command

The last row is the one most often decisive in practice and least often considered at design time. A technician with a serial console can run three SQL statements against an MBTiles archive and answer “which zoom levels are present for this area” without any custom tooling. Getting the same answer from a PMTiles file means shipping a command that can read it — which is worth doing regardless, and is a real cost in an image where every binary is scrutinised.

Archive overhead and lookup latency measured on the same 314 MB basemap The same metro basemap encoded both ways. MBTiles occupies 331 megabytes, 5.4 percent of it index overhead, with a warm lookup of about 40 microseconds and a cold lookup of 2.6 milliseconds. PMTiles occupies 317 megabytes, 0.9 percent overhead, with a warm lookup of about 8 microseconds and a cold lookup of 1.4 milliseconds. Resident memory attributable to the reader is 780 kilobytes for MBTiles against 42 kilobytes for PMTiles. Same 46 000 tiles, encoded both ways file sizeindex overheadwarm lookupreader RSS MBTiles 331 MB5.4%40 µs780 KB PMTiles 317 MB0.9%8 µs42 KB Choose MBTiles when… SQLite is already carrying the spool and reference layer, and field debuggability with stock tools matters. Choose PMTiles when… the reader must be tiny or non-Python, or the same file is also served by range request from a hub.
The size and latency differences are real and small. The decisive factors are what else the device already runs and who has to debug the archive in the field.

Gotchas and edge cases

  • The y axis. MBTiles stores rows in TMS order (y counted from the south); PMTiles uses Hilbert-ordered tile ids derived from XYZ coordinates. Convert at exactly one boundary and assert it with a fixture: pick a tile containing a recognisable coastline and compare the rendered output against the source. A silent flip renders a plausible map of the wrong place.
  • Compression is inside the tile, not the container. Both formats store already-gzipped vector tiles and record that fact in metadata. A reader that gzips again on read wastes cycles; one that fails to decompress passes compressed bytes to the renderer and produces an empty map with no error.
  • SQLite’s immutable=1 is a promise, not a hint. It tells SQLite the file will not change while open. Violating it — by swapping the archive under a live reader — produces undefined results rather than an error. Close, swap, reopen.
  • PMTiles directory depth varies with archive size. Small archives have a single root directory and no leaves; large ones add a leaf level. A reader that assumes one level works perfectly until the first archive that crosses the threshold.
  • Neither format stores an integrity hash. Add one alongside — the same detached signature used for signed tile and config bundles — because a truncated archive is otherwise indistinguishable from one that simply lacks coverage.
  • Metadata differs. MBTiles’ metadata table and PMTiles’ JSON metadata block carry the same information under different names. Normalise both into one internal structure at load time so the rest of the code does not branch on container type.
The same tile addressed in XYZ and in TMS row order A four-by-four tile grid at zoom two. In XYZ addressing the row index counts from the north, so the tile containing a northern coastline is row one. In TMS addressing the row index counts from the south, so the same tile is row two. The conversion is row equals two to the power z minus one minus y. A wrongly converted request returns a real tile from the mirrored latitude, which renders cleanly and is wrong. The bug that renders perfectly and shows the wrong hemisphere XYZ — row counts from the north y = 1 y = 0y = 2y = 3 TMS — row counts from the south row = 2 row = 3row = 1row = 0 row = (1 << z) − 1 − y — apply it at exactly one layer, and pin it with a fixture tile in the test suite.
Both addresses are valid and both return data. Only one of them returns the place the viewport asked for.

Integration with the tile server

Both readers slot behind the same interface, which is what keeps the container a deployment decision:

# tile_source.py — one interface, two backends, chosen at start-up.
from pathlib import Path

class TileSource:
    """Resolve (z, x, y) to bytes, whatever the container underneath."""

    def __init__(self, path: str):
        p = Path(path)
        if p.suffix == ".pmtiles":
            from pmtiles_reader import PMTiles
            self._impl = PMTiles(str(p))
            self.kind = "pmtiles"
        else:
            from edge_tiles import TileArchive
            self._impl = TileArchive(str(p))
            self.kind = "mbtiles"

    def get(self, z: int, x: int, y: int) -> bytes | None:
        return self._impl.get(z, x, y)

Wire that into the local HTTP surface described in serving vector tiles from a read-only SQLite cache, and record kind in the health snapshot so a field diagnosis never has to guess which container is mounted.