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-endheaders and process206 Partial Contentresponses per RFC 7233. - Little-endian binary parsing: Python
struct(<Q,<B) or the Node.jsDataViewAPI. 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 206if you layer a reverse proxy. - Compression libraries:
zstdorgzipfor 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 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.
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:
- Binary-search the root directory for the largest
tile_id≤ target. - If
run_length > 0, the tile is in the run — compute its byte offset asoffset + (target − tile_id) × length. - If
run_length == 0, fetch and decompress the leaf directory atleaf_dirs_offset + offset, then repeat step 1 within that leaf. - 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.
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
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.
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)
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:
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:
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:
# 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:
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:
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:
# 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:
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:
{
"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:
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
- How to Inspect PMTiles Metadata with CLI Tools — verifying tile counts, zoom ranges, bounding boxes, and layer metadata using the
pmtilesCLI and Python without fetching tile data. - PMTiles vs MBTiles for CDN Delivery — the decision that shapes your serving stack: a static single-file archive read by HTTP range requests versus a SQLite container behind a tile server.
Parent: Vector Tile Architecture & Format Fundamentals
Related:
- MBTiles Architecture & Limits — SQLite-based tile archives and where their locking and write-concurrency constraints make PMTiles the better choice for read-heavy CDN delivery.
- Zoom Level Optimization Strategies — choosing
min_zoom/max_zoomvalues that keep root directory size manageable and tile payloads under 500 KB. - Geometry Simplification Algorithms — controlling per-tile vertex density in Tippecanoe output before packaging into a
.pmtilesarchive.