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.

PMTiles v3 archive byte layout Diagram showing the five sections of a PMTiles file arranged left to right: Header (127 bytes), Root Directory (variable), JSON Metadata (variable), Leaf Directories (variable), and Tile Data (variable). Arrows beneath show that the header holds the offset and length of every other section. Header 127 bytes offset 0 Root Directory variable JSON Metadata variable Leaf Directories variable (optional) Tile Data raw tile bytes variable dir_offset / dir_length meta_offset / meta_length leaf_dirs_offset / leaf_dirs_length

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.

Hilbert curve tile ordering vs row-major XYZ Two 4x4 grids of zoom-2 tiles side by side. The left grid shows XYZ row-major numbering 0-15 left-to-right top-to-bottom. The right grid shows Hilbert curve numbering where adjacent tile IDs stay geographically close, illustrated with a connected path. Row-major (XYZ) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Fetching a 2×2 viewport may scatter across IDs Hilbert curve ordering 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Spatial neighbours stay numerically close

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.

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.

Further Reading


Parent: Vector Tile Architecture & Format Fundamentals

Related:

Next reading How to Inspect PMTiles Metadata with CLI Tools Next reading PMTiles vs MBTiles for CDN Delivery