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. Fetchoffset..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 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.
Production Command: Reading the Directories
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.
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.
Related
- PMTiles Specification Deep Dive — the parent topic and the five sections of the archive.
- PMTiles Range-Request Delivery — how these ranges reach a browser.
- Converting MBTiles to PMTiles — where the directory is built.
- Debugging HTTP 416 Range-Request Failures — what happens when an offset points past the end of the object.