PMTiles Directory Structure and Leaf Lookups

A PMTiles archive indexes its tiles with a sorted list of entries mapping a Hilbert tile id to a byte offset, a length and a run length. Small archives fit that list in one root directory. Large ones split it: the root holds coarse entries that point at leaf directories, and a lookup that lands on a leaf costs one extra range request before the tile itself can be fetched.

When to Use This

You need this when the first tile of a cold map takes noticeably longer than the rest, when tuning an archive for a latency-sensitive deployment, or when deciding whether an archive’s shape is contributing to a slow initial render. It is also the mechanism behind run_length, which is what makes deduplicated tiles cheap.

Specification Detail: The Directory Entry

Field Meaning
tile_id The Hilbert curve id of the first tile this entry covers
offset Byte offset of the tile data, relative to the tile data section
length Byte length of the tile
run_length How many consecutive tile ids share this entry — 0 means the entry points at a leaf directory

Entries are sorted by tile_id, so a lookup is a binary search. Three cases follow from run_length:

  • run_length == 1 — an ordinary tile. Fetch offset..offset+length.
  • run_length > 1 — this many consecutive tile ids all resolve to the same bytes. This is how deduplication is expressed: a run of identical ocean tiles is one entry.
  • run_length == 0 — the entry points at a leaf directory covering that id range. Fetch and search the leaf, then repeat.
The four numbers that make up a directory entryA PMTiles directory entry split into tile id, offset, length and run length, with the special meaning of a zero run length highlighted.ENTRY LAYOUTtile_idHilbertfirst id this entrycoversoffsetvarintrelative to the tiledata sectionlengthvarintbytes of the tilerun_length0 / 1 / n0 defers to a leafdirectory
run_length = 0 is the only overloaded value in the format, and it is what turns a one-hop lookup into a two-hop one.

The Two-Level Lookup

Archives with more entries than fit comfortably in a single fetched blob split into two levels. The threshold is not fixed by the spec; the reference implementation targets a root directory small enough to fetch in one request, typically under 16 KB, and pushes the remainder into leaves.

A lookup that lands on a leaf costs one extra round tripThe client searches the cached root directory, finds an entry with run length zero, fetches the leaf directory it points at, searches that, and only then requests the tile.ClientCDN edgeArchivebinary search the cached root directoryentry has run_length 0 - fetch leaf rangemiss - forward the range206 leaf directorycached at the edge afterwardsRange: tile offset + length206 tile bytes
The root is fetched once per session and cached. The leaf is fetched per region — which is why a viewport that pans into a new area can feel slower than one that stays put.

Production Command: Reading the Directories

python
import gzip
import struct
import requests

HEADER_LEN = 127

def varint(buf, pos):
    result = shift = 0
    while True:
        byte = buf[pos]; pos += 1
        result |= (byte & 0x7F) << shift
        if not byte & 0x80:
            return result, pos
        shift += 7


def parse_directory(raw: bytes) -> list[dict]:
    """Decode a PMTiles v3 directory: delta tile ids, then lengths, then offsets."""
    n, pos = varint(raw, 0)
    entries = [{} for _ in range(n)]

    tile_id = 0
    for e in entries:
        delta, pos = varint(raw, pos)
        tile_id += delta
        e["tile_id"] = tile_id
    for e in entries:
        e["run_length"], pos = varint(raw, pos)
    for e in entries:
        e["length"], pos = varint(raw, pos)
    for i, e in enumerate(entries):
        value, pos = varint(raw, pos)
        if value == 0 and i > 0:
            e["offset"] = entries[i - 1]["offset"] + entries[i - 1]["length"]
        else:
            e["offset"] = value - 1
    return entries


url = "https://tiles.example.com/v43/basemap.pmtiles"
header = requests.get(url, headers={"Range": "bytes=0-126"}).content
root_off, root_len = struct.unpack_from("<QQ", header, 8)
root_raw = requests.get(
    url, headers={"Range": f"bytes={root_off}-{root_off + root_len - 1}"}).content

entries = parse_directory(gzip.decompress(root_raw))
leaves = sum(1 for e in entries if e["run_length"] == 0)
runs = sum(1 for e in entries if e["run_length"] > 1)
print(f"{len(entries)} root entries, {leaves} pointing at leaves, "
      f"{runs} deduplicated runs, {root_len} bytes")

The three numbers that print are the diagnostic. A root with many leaf pointers means most lookups will take two hops; a root with many long runs means deduplication is doing real work; and the root’s byte length tells you whether it is on the fast path at all.

When an archive outgrows a single-level directoryRoot directory size for four archives of increasing distinct tile count, showing the point at which leaf directories are introduced.DIRECTORY SIZEKB of root directory0.4 M tiles — single level6 KB1.6 M tiles — single level14 KB12 M tiles — with leaves15 KB + leaves40 M tiles — with leaves16 KB + leaves
The threshold tracks distinct tiles, not file size. A heavily deduplicated global archive can have a smaller directory than a smaller one that deduplicates poorly.

Interaction Effects

With CDN caching. Root and leaf directories are byte ranges like any other and are cached at the edge independently. The first reader in a region pays for the leaf fetch; every reader afterwards does not. This is why the effect is most visible in low-traffic regions of a high-traffic archive.

With Hilbert ordering. Because the curve preserves locality, a leaf directory covers a contiguous geographic region as well as a contiguous id range. A viewport rarely straddles more than one or two leaves, which is what keeps the two-hop cost bounded.

With archive size. The number of leaves grows with distinct tile count, not file size. A large archive that deduplicates heavily can have a smaller directory than a smaller one that does not.

Performance Impact

Measured against a global basemap on a CDN with a 40 ms edge RTT:

Scenario Requests before first tile Added latency
Root fits, warm edge 1 (tile only) 0 ms
Root fits, cold 3 (header, root, tile) ~80 ms
Leaf hop, warm leaf 1 0 ms
Leaf hop, cold leaf 4 ~120 ms

The pattern is that everything is fast once warm, and cold paths differ by one round trip. For a public map with steady traffic this is close to irrelevant; for an internal tool with a handful of users a day, most requests are cold and the difference is visible.

Common Mistakes

Assuming a bigger root is always better. Pushing everything into the root removes the leaf hop and makes the root itself a large fetch on the critical path. The default balance is well chosen; changing it is worth measuring rather than assuming.

Treating run_length == 0 as an error. It is a valid, common entry. A hand-written client that skips it will fail to find every tile in the affected range.

Ignoring the delta encoding. Tile ids in a directory are stored as deltas, not absolute values, and the offsets have a special zero meaning “immediately after the previous entry”. Both are easy to miss when writing a parser from the field table alone.

FAQ

How do I tell whether my archive uses leaves at all?

Parse the root directory and count entries with run_length == 0, as in the script above. Zero means every lookup is single-hop. Anything above zero means some regions take an extra request.

Can I force a single-level archive?

Not through a documented flag in the reference tooling, and it is rarely worth pursuing. An archive large enough to need leaves has a root that would be megabytes if flattened, which moves the cost onto every session rather than onto the regions that need it.

Does the leaf hop happen on every tile?

No. The leaf is cached by the client for the session and by the CDN for everyone, so it is paid once per region per cache lifetime, not once per tile.

Why are tile ids delta-encoded?

Because the list is sorted, consecutive ids differ by small numbers, and small numbers are cheap as varints. It is the same reasoning behind the delta encoding of geometry coordinates inside a tile.