PMTiles Specification Deep Dive

PMTiles is a single-file archive format for tilesets that replaces directory trees and SQLite databases with a flat binary structure designed for HTTP byte-range delivery. Understanding its internal layout is a prerequisite for building low-latency tile delivery pipelines, writing custom readers, or diagnosing range-request failures in production — topics that sit at the core of Vector Tile Architecture & Format Fundamentals.

Prerequisites

Before working through the implementation sections, confirm your environment meets these requirements:

  • HTTP range requests: Your HTTP client must be able to issue Range: bytes=start-end headers and process 206 Partial Content responses per RFC 7233.
  • Little-endian binary parsing: Python struct (<Q, <B) or the Node.js DataView API. All multi-byte integers in PMTiles are little-endian.
  • Object storage with range support: AWS S3, Google Cloud Storage, and Cloudflare R2 all support byte-range fetches natively. Nginx requires proxy_cache_valid 206 if you layer a reverse proxy.
  • Compression libraries: zstd or gzip for directory decompression; the tile payload uses whichever codec is recorded in the header.
  • Background knowledge: Familiarity with the MBTiles SQLite container helps clarify what PMTiles was designed to replace.

The complete normative reference is the PMTiles v3 specification.

Core Concept: Binary Layout and the Five Sections

A PMTiles archive is a single byte stream divided into five contiguous, non-overlapping sections. There is no padding, no alignment requirement, and no internal file system. All section offsets and lengths are encoded in the fixed 127-byte header at position 0.

The five contiguous sections of a PMTiles v3 archiveHeader, root directory, JSON metadata, leaf directories and tile data laid out in file order, with the header carrying the offset and length of every other section.BINARY LAYOUTHeaderbytes 0-126, fixedMagic, version, and the offset and length ofall four sections below it.Root directorytypically < 16 KBSorted entries of tile id, offset, length,run length. Fetched once and cached for thesession.JSON metadatagzip by defaultTileJSON-shaped: vector_layers, attribution,bounds. Never on the tile read path.Leaf directoriesonly above ~16k tilesEntries the root defers to with run_length =0.Tile datathe rest of the fileHilbert-ordered tile blobs, deduplicated soidentical tiles share one byte range.
No padding, no alignment, no internal file system — every section is found by an offset and a length read out of the first 127 bytes.

The header byte offsets for these pointers are fixed and version-stable in PMTiles v3:

Section Offset Field (bytes in header) Length Field
Root directory 8–15 (uint64 LE) 16–23
JSON metadata 24–31 32–39
Leaf directories 40–47 48–55
Tile data 56–63 64–71

Fixed-Length Header: All 127 Bytes

The header is immutable in size. A client needs exactly one HTTP range request (Range: bytes=0-126) to bootstrap parsing.

Offset Size Field Notes
0–6 7 B Magic bytes ASCII PMTiles
7 1 B version 3 for current spec
8–15 8 B root_dir_offset uint64 LE
16–23 8 B root_dir_length uint64 LE
24–31 8 B json_metadata_offset uint64 LE
32–39 8 B json_metadata_length uint64 LE
40–47 8 B leaf_dirs_offset uint64 LE
48–55 8 B leaf_dirs_length uint64 LE
56–63 8 B tile_data_offset uint64 LE
64–71 8 B tile_data_length uint64 LE
72–79 8 B n_addressed_tiles uint64 LE
80–87 8 B n_tile_entries uint64 LE
88–95 8 B n_tile_contents uint64 LE
96 1 B clustered 1 if tile data is Hilbert-ordered
97 1 B internal_compression Codec for directories and metadata
98 1 B tile_compression Codec for individual tile payloads
99 1 B tile_type Format of each tile
100 1 B min_zoom uint8
101 1 B max_zoom uint8
102–109 8 B min_lon_e7, min_lat_e7 int32 pairs × 1e7
110–117 8 B max_lon_e7, max_lat_e7 int32 pairs × 1e7
118 1 B center_zoom uint8
119–126 8 B center_lon_e7, center_lat_e7 int32 pairs × 1e7

Tile type enum (byte 99):

Value Meaning
0 Unknown / other
1 MVT (Mapbox Vector Tile)
2 PNG
3 JPEG
4 WebP
5 AVIF

Compression enum (bytes 97 and 98):

Value Codec
0 Unknown
1 None
2 gzip
3 Brotli
4 Zstandard (zstd)

Byte 97 (internal_compression) applies to both directory sections and the JSON metadata block. Byte 98 (tile_compression) applies to every tile payload uniformly. A common production configuration is internal_compression=4 (zstd) and tile_compression=2 (gzip) for MVT archives, because gzip-compressed MVT payloads can be served directly to browsers that send Accept-Encoding: gzip.

Hilbert Curve Tile IDs and the Directory Structure

PMTiles identifies tiles by a single integer derived from a Hilbert space-filling curve, not by z/x/y tuples. The Hilbert mapping converts two-dimensional tile coordinates into a one-dimensional sequence that preserves geographic locality: tiles that are spatially adjacent on the map end up numerically close on the curve.

How a z/x/y address becomes a byte rangeThe client converts z, x and y into a single Hilbert tile id, binary-searches the root directory, follows a leaf directory when the entry defers, and finally issues a range request for the tile's offset and length.LOOKUP PATHz / x / y14 / 8192 / 5461Hilbert idone 64-bit integerRoot directorybinary searchalready in memoryLeaf directoryonly if run_length = 0one extra range requestRange requestoffset + length
Locality is the point: Hilbert ordering puts geographically adjacent tiles adjacent in the file, so the ranges for one viewport coalesce into a few requests instead of one per tile.

Each directory entry records five varint-encoded fields:

Field Meaning
tile_id Hilbert curve ID of the first tile in this run
run_length Number of consecutive tile IDs this entry covers (0 = leaf directory pointer)
length Byte length of each tile in the run (all equal within a run)
offset Byte offset relative to tile_data_offset for the first tile
If run_length=0, offset points into the leaf directories section

Directory entries are varint-compressed and then compressed again with the codec from internal_compression. This double compression can shrink a dense zoom-14 directory from several megabytes to under 200 KB.

Two-Level Directory Lookup

For archives with more than approximately 16,000 tiles, PMTiles uses a two-level structure: the root directory at the header’s root_dir_offset covers coarse ranges, while leaf directories handle fine-grained lookups. An entry with run_length=0 signals that its offset field points to a leaf directory blob within the leaf directories section, not to tile data.

The requests a cold client makes before the first tile is drawnA browser fetches the 127-byte header, then the root directory, then a leaf directory, then the tile itself, with the CDN caching each range after the first miss.MapLibre clientCDN edgeR2 / S3Range: bytes=0-126miss - forward range206 headercached at the edgeRange: root directory206 root dirheld for the sessionRange: leaf directoryonly above ~16k tilesRange: tile offset+length206 tile bytesedge hit for every later reader
Four round trips cold, one warm. Keeping the root directory small is what removes the leaf hop from the critical path for the common viewport.

Lookup algorithm:

  1. Binary-search the root directory for the largest tile_id ≤ target.
  2. If run_length > 0, the tile is in the run — compute its byte offset as offset + (target − tile_id) × length.
  3. If run_length == 0, fetch and decompress the leaf directory at leaf_dirs_offset + offset, then repeat step 1 within that leaf.
  4. A miss (no entry found) means the tile does not exist in the archive (no-data tile).

Step-by-Step Implementation

Step 1 — Parse the Header

Fetch exactly 127 bytes. Validate magic bytes and version before reading any offsets.

python
import struct
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def build_session() -> requests.Session:
    s = requests.Session()
    retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
    s.mount("https://", HTTPAdapter(max_retries=retry))
    return s

def parse_header(url: str, session: requests.Session) -> dict:
    """Fetch and validate the 127-byte PMTiles v3 header."""
    resp = session.get(url, headers={"Range": "bytes=0-126"})
    resp.raise_for_status()
    if resp.status_code != 206:
        raise RuntimeError(f"Server returned {resp.status_code}; range requests unsupported")

    h = resp.content
    if h[:7] != b"PMTiles":
        raise ValueError("Not a PMTiles archive (bad magic bytes)")
    version = h[7]
    if version != 3:
        raise NotImplementedError(f"PMTiles version {version} not supported")

    # All offsets/lengths: little-endian uint64
    def u64(start): return struct.unpack_from("<Q", h, start)[0]
    def u8(pos):    return h[pos]

    return {
        "root_dir_offset":    u64(8),
        "root_dir_length":    u64(16),
        "meta_offset":        u64(24),
        "meta_length":        u64(32),
        "leaf_dirs_offset":   u64(40),
        "leaf_dirs_length":   u64(48),
        "tile_data_offset":   u64(56),
        "tile_data_length":   u64(64),
        "n_addressed_tiles":  u64(72),
        "n_tile_entries":     u64(80),
        "n_tile_contents":    u64(88),
        "clustered":          u8(96),
        "internal_compression": u8(97),
        "tile_compression":   u8(98),
        "tile_type":          u8(99),
        "min_zoom":           u8(100),
        "max_zoom":           u8(101),
    }

Verify: resp.status_code == 206, header["tile_type"] == 1 for MVT, header["max_zoom"] <= 14 for typical basemap archives.

Step 2 — Decompress and Search the Root Directory

python
import zlib, zstandard

def decompress(data: bytes, codec: int) -> bytes:
    if codec == 1: return data          # no compression
    if codec == 2: return zlib.decompress(data, wbits=47)   # gzip / zlib auto
    if codec == 4: return zstandard.ZstdDecompressor().decompress(data)
    raise ValueError(f"Unsupported internal_compression codec: {codec}")

def read_varint(buf: bytes, pos: int):
    """Read a single unsigned varint; return (value, new_pos)."""
    result, shift = 0, 0
    while True:
        b = buf[pos]; pos += 1
        result |= (b & 0x7F) << shift
        if not (b & 0x80): return result, pos
        shift += 7

def parse_directory(data: bytes) -> list[dict]:
    """Parse a decompressed PMTiles directory into a list of entries."""
    n_entries, pos = read_varint(data, 0)
    entries = []
    tile_id = 0
    for _ in range(n_entries):
        delta, pos = read_varint(data, pos)
        tile_id += delta
        run_length, pos = read_varint(data, pos)
        length,     pos = read_varint(data, pos)
        offset,     pos = read_varint(data, pos)
        entries.append({"tile_id": tile_id, "run_length": run_length,
                         "length": length, "offset": offset})
    return entries

def fetch_range(url: str, offset: int, length: int, session: requests.Session) -> bytes:
    end = offset + length - 1
    resp = session.get(url, headers={"Range": f"bytes={offset}-{end}"})
    resp.raise_for_status()
    return resp.content

Verify: len(entries) == header["n_tile_entries"] for the root directory of small archives.

Step 3 — Look Up a Tile by z/x/y

Convert z/x/y to a Hilbert tile ID, then binary-search the directory.

python
import bisect

def zxy_to_hilbert(z: int, x: int, y: int) -> int:
    """Convert tile z/x/y to PMTiles Hilbert tile ID (Python reference implementation)."""
    if z == 0: return 0
    n = 1 << z
    # Flip y for TMS→XYZ if needed — PMTiles uses XYZ (y=0 at top)
    acc = 0
    for i in range(z - 1, -1, -1):
        rx = 1 if (x & (1 << i)) else 0
        ry = 1 if (y & (1 << i)) else 0
        acc += (1 << (2 * i)) * ((3 * rx) ^ ry)
        # Rotate
        if ry == 0:
            if rx == 1:
                x = (1 << i) - 1 - x
                y = (1 << i) - 1 - y
            x, y = y, x
    return acc

def find_tile(entries: list[dict], tile_id: int) -> dict | None:
    ids = [e["tile_id"] for e in entries]
    idx = bisect.bisect_right(ids, tile_id) - 1
    if idx < 0: return None
    e = entries[idx]
    if e["run_length"] > 0 and tile_id < e["tile_id"] + e["run_length"]:
        return {"offset": e["offset"] + (tile_id - e["tile_id"]) * e["length"],
                "length": e["length"], "is_leaf": False}
    if e["run_length"] == 0:
        return {"offset": e["offset"], "length": e["length"], "is_leaf": True}
    return None

def get_tile(url: str, z: int, x: int, y: int,
             header: dict, root_entries: list[dict],
             session: requests.Session) -> bytes | None:
    tile_id = zxy_to_hilbert(z, x, y)
    result  = find_tile(root_entries, tile_id)
    if result is None: return None

    if result["is_leaf"]:
        leaf_raw = fetch_range(url,
            header["leaf_dirs_offset"] + result["offset"],
            result["length"], session)
        leaf_entries = parse_directory(decompress(leaf_raw, header["internal_compression"]))
        result = find_tile(leaf_entries, tile_id)
        if result is None or result["is_leaf"]: return None

    tile_bytes = fetch_range(url,
        header["tile_data_offset"] + result["offset"],
        result["length"], session)
    return tile_bytes   # still compressed per header["tile_compression"]

Verify: Non-None return for a known existing tile; None for a z/x/y outside min_zoom/max_zoom or outside the bounding box.

Step 4 — Node.js Equivalent (Browser / Edge Worker)

javascript
async function parsePMTilesHeader(url) {
  const res = await fetch(url, { headers: { Range: "bytes=0-126" } });
  if (res.status !== 206) throw new Error(`Range unsupported: ${res.status}`);
  const buf = await res.arrayBuffer();
  const v   = new DataView(buf);
  const magic = new TextDecoder().decode(buf.slice(0, 7));
  if (magic !== "PMTiles") throw new Error("Bad magic bytes");
  if (v.getUint8(7) !== 3) throw new Error(`Unsupported version: ${v.getUint8(7)}`);

  const u64 = (off) => Number(v.getBigUint64(off, true));   // little-endian
  return {
    rootDirOffset:       u64(8),  rootDirLength:  u64(16),
    metaOffset:          u64(24), metaLength:     u64(32),
    leafDirsOffset:      u64(40), leafDirsLength: u64(48),
    tileDataOffset:      u64(56), tileDataLength: u64(64),
    clustered:           v.getUint8(96),
    internalCompression: v.getUint8(97),
    tileCompression:     v.getUint8(98),
    tileType:            v.getUint8(99),
    minZoom:             v.getUint8(100),
    maxZoom:             v.getUint8(101),
  };
}

async function fetchRange(url, offset, length) {
  const end = offset + length - 1;
  const res = await fetch(url, { headers: { Range: `bytes=${offset}-${end}` } });
  if (!res.ok) throw new Error(`Range fetch failed: ${res.status}`);
  return new Uint8Array(await res.arrayBuffer());
}

Verify: header.tileType === 1 for MVT archives; header.internalCompression === 4 for zstd-compressed directories (requires a Wasm zstd decoder in the browser).

Optimization Knobs

Parameter Low High Trade-off
internal_compression codec 1 (none) 4 (zstd) No decompression latency vs. 60–80% smaller directories; choose zstd level 3 for best balance
Archive clustering (clustered=1) Disabled Enabled Viewport fetches may need many separate range requests vs. adjacent tile IDs coalesce into fewer requests — always enable for CDN delivery
Tile payload compression 1 (none) 2 (gzip) Larger payloads, direct passthrough vs. smaller payloads but requires client decompression; gzip tiles served with Content-Encoding: gzip decompress transparently in browsers

For tile compression decisions that affect rendering, see the discussion of vector vs raster tile tradeoffs and how codec choice interacts with payload size budgets (the 500 KB per-tile ceiling applies to the uncompressed MVT before any container compression).

Integration with Adjacent Pipeline Stages

Generating PMTiles Archives with Tippecanoe

Tippecanoe can write .pmtiles directly since version 2.17. Combine this with geometry simplification to keep individual tiles under 500 KB:

bash
tippecanoe \
  --output=output/buildings.pmtiles \
  --layer=buildings \
  --minimum-zoom=6 \
  --maximum-zoom=14 \
  --simplification=8 \
  --drop-densest-as-needed \
  --force \
  input/buildings.geojson

Verify the output header before uploading:

bash
pmtiles show output/buildings.pmtiles
# Expected: version=3, tile_type=mvt, min_zoom=6, max_zoom=14

Uploading to Cloud Storage

Upload with the correct CORS and cache headers so browsers can issue cross-origin range requests:

bash
# Cloudflare R2 via wrangler
wrangler r2 object put tiles-bucket/buildings.pmtiles \
  --file output/buildings.pmtiles \
  --content-type application/octet-stream

# AWS S3
aws s3 cp output/buildings.pmtiles s3://my-tile-bucket/buildings.pmtiles \
  --content-type application/octet-stream \
  --cache-control "public, max-age=31536000, immutable"

Apply a bucket-level CORS policy that permits Range, If-Match, and If-None-Match headers. Missing CORS on Range is the most common cause of 206 responses being blocked by browsers on cross-origin PMTiles.

Serving with MapLibre GL JS

The protomaps/maplibre-pmtiles library patches MapLibre’s protocol handler so that pmtiles:// URLs work transparently with the MapLibre GL JSON style structure:

javascript
import { Protocol } from "pmtiles";
import maplibregl from "maplibre-gl";

const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile.bind(protocol));

const map = new maplibregl.Map({
  container: "map",
  style: {
    version: 8,
    sources: {
      buildings: {
        type: "vector",
        url: "pmtiles://https://cdn.example.com/tiles/buildings.pmtiles",
      },
    },
    layers: [/* ... */],
  },
});

Once the archive is live, use CLI tools to inspect its metadata and directory structure to confirm zoom coverage, bounding box, and tile counts before enabling production traffic.

Troubleshooting

1. Server returns 200 instead of 206

Symptom: Client fetches the full archive on every tile request; memory spikes to archive size.

Diagnosis:

bash
curl -I -H "Range: bytes=0-126" https://cdn.example.com/tiles/buildings.pmtiles | grep -i "content-range\|accept-ranges\|status"

Fix: The origin does not honour Range headers. Common causes: Nginx missing proxy_cache_valid 206 1d;; a CDN or WAF stripping range headers. For S3 origins ensure the bucket policy does not block GetObjectRange. Cloudflare R2 supports range requests with no configuration.

2. Directory decompression fails with zstd codec

Symptom: ZstdError: Unknown frame descriptor when parsing directory bytes.

Diagnosis: Check internal_compression byte (offset 97) in the header. If it reads 4 but the decompressor expects gzip, the codec mismatch will surface here.

Fix:

python
# Always use the codec from the header, not a hardcoded value
data = decompress(raw_bytes, header["internal_compression"])

3. find_tile returns None for a valid z/x/y

Symptom: Tile requests return 404 or empty responses for tiles that should exist.

Diagnosis:

bash
pmtiles show buildings.pmtiles          # confirm min/max zoom
pmtiles tile buildings.pmtiles 10 512 512  # test a known tile

Fix: Verify that zxy_to_hilbert uses the same y orientation as the tileset. PMTiles uses XYZ (y=0 at top). TMS archives (y=0 at bottom) require y = (1 << z) - 1 - y before conversion.

4. CORS blocks cross-origin range requests in the browser

Symptom: CORS error in browser console; preflight OPTIONS request returns no Access-Control-Allow-Headers: Range.

Fix: Add to your S3/R2 bucket CORS configuration:

json
{
  "AllowedHeaders": ["Range", "If-Match", "If-None-Match"],
  "AllowedMethods": ["GET", "HEAD"],
  "AllowedOrigins": ["https://your-map-app.com"],
  "ExposeHeaders": ["Content-Range", "Accept-Ranges", "ETag"]
}

5. Oversized root directory causes slow initial tile load

Symptom: First tile render takes 2–4 seconds; network waterfall shows a large second request after the 127-byte header.

Diagnosis:

bash
pmtiles show buildings.pmtiles | grep "root directory"
# Root directory size should be < 16 KB for fast initial loads

Fix: Regenerate the archive with a smaller --maximum-zoom. A zoom-14 global tileset can produce a root directory exceeding 1 MB. Split the archive by region or use the two-level directory to push large indexes into leaf directories that are only fetched on demand.

FAQ

How many requests does a cold PMTiles client make before the first tile?

Three or four: the 127-byte header, the root directory, a leaf directory if the archive is large enough to need one, and the tile itself. Every one of them is cached at the edge afterwards.

Can a PMTiles archive be updated in place?

No. It is written sequentially with a directory of byte offsets, so changing one tile means rewriting the archive. That is why PMTiles pairs naturally with versioned prefixes rather than with purging.

Why Hilbert ordering rather than z/x/y?

Because a Hilbert curve preserves locality: tiles that are near each other on the map end up near each other in the file. A viewport’s tiles therefore occupy a few contiguous byte ranges that coalesce into a handful of requests.

Does the archive have to be public?

No. Signed URLs work, at the cost of a query string that fragments the CDN cache key unless the CDN is configured to exclude the signature from it.

Further Reading


Parent: Vector Tile Architecture & Format Fundamentals

Related:

Next reading Converting MBTiles to PMTiles with pmtiles convert Next reading How to Inspect PMTiles Metadata with CLI Tools Next reading PMTiles Directory Structure and Leaf Lookups Next reading PMTiles vs MBTiles for CDN Delivery