How to Inspect PMTiles Metadata with CLI Tools

Run pmtiles show archive.pmtiles to dump the complete JSON header from any PMTiles archive — exposing tile type, compression algorithm, zoom range, bounding coordinates, and the embedded metadata object — without loading the entire file into RAM.

When to Use This

Use pmtiles show in these specific situations:

  • Post-build validation: confirm that a freshly generated archive has the expected tileType, tileCompression, and zoom range before uploading to cloud storage.
  • Debugging decoder errors: a tile server that refuses to serve tiles often fails because the declared tileCompression does not match what the server expects — inspection reveals the mismatch immediately.
  • CI/CD gates: pmtiles show output is JSON, so jq -e can fail a pipeline automatically when archive properties drift from spec.
  • Remote archive triage: the CLI accepts HTTP/HTTPS URLs and fetches only the header bytes via HTTP range requests, so you can inspect a live archive on S3 or Cloudflare R2 without downloading it.

Do not use pmtiles show as a substitute for tile-level validation — it reads the header and metadata sections only, not individual tile payloads. For payload-level checks, use pmtiles verify or parse tiles with the Python pmtiles library.

Specification Detail: Header Fields Returned by pmtiles show

The tool reads the 127-byte fixed header defined in the PMTiles v3 spec and surfaces its fields as camelCase JSON keys. The integer enum fields are the most frequently misread:

tileType enum:

Value Meaning Content-Type header
0 Unknown / Other
1 MVT vector tile application/x-protobuf
2 PNG image/png
3 JPEG image/jpeg
4 WebP image/webp
5 AVIF image/avif

tileCompression / internalCompression enum:

Value Meaning Decompress with
0 Unknown
1 None (uncompressed) no-op
2 gzip gunzip / zlib
3 Brotli brotli -d
4 Zstandard (zstd) zstd -d

internalCompression applies to the directory index sections; tileCompression applies to individual tile payloads. Both fields must match your tile server’s decoder configuration or every tile request will return a decode error.

Coordinate fields use E7 encoding in the raw binary header (integer value = coordinate × 10,000,000), but pmtiles show converts them to decimal degrees in the JSON output, matching the [min_lon, min_lat, max_lon, max_lat] convention used in TileJSON.

Installation

The official CLI is a single Go binary from protomaps/go-pmtiles. No runtime dependencies are required.

bash
# Linux (x86_64) — replace VERSION with the latest release tag
VERSION=1.22.0
curl -L "https://github.com/protomaps/go-pmtiles/releases/download/v${VERSION}/go-pmtiles_${VERSION}_Linux_x86_64.tar.gz" \
  | tar xz pmtiles
sudo mv pmtiles /usr/local/bin/

# macOS (Homebrew)
brew install protomaps/homebrew-tools/go-pmtiles

# Verify
pmtiles --version

The binary validates the PMTiles v3 magic bytes (PMTiles at offset 0–6) before reading any other field; an archive produced by an older tool or a truncated upload will fail immediately with a clear error.

Production Command

A single block for inspecting a local archive, extracting only the fields relevant to pipeline validation, and asserting the expected values:

bash
#!/usr/bin/env bash
set -euo pipefail

ARCHIVE="dist/world.pmtiles"

# --- Dump full header for logging ---
echo "=== PMTiles header ===" >&2
pmtiles show "$ARCHIVE" | jq . >&2

# --- Extract key fields ---
HEADER=$(pmtiles show "$ARCHIVE")

TILE_TYPE=$(echo "$HEADER"     | jq -r '.tileType')
TILE_COMP=$(echo "$HEADER"     | jq -r '.tileCompression')
MIN_ZOOM=$(echo "$HEADER"      | jq -r '.minZoom')
MAX_ZOOM=$(echo "$HEADER"      | jq -r '.maxZoom')
BOUNDS=$(echo "$HEADER"        | jq -r '.bounds | @json')
VEC_LAYERS=$(echo "$HEADER"    | jq -r '.metadata.vector_layers | length')

echo "tileType=${TILE_TYPE} tileCompression=${TILE_COMP} zoom=${MIN_ZOOM}-${MAX_ZOOM}"
echo "bounds=${BOUNDS}"
echo "vector_layers=${VEC_LAYERS}"

# --- Hard assertions ---
# tileType=1 (MVT), tileCompression=2 (gzip)
echo "$HEADER" | jq -e '.tileType == 1 and .tileCompression == 2' > /dev/null \
  || { echo "ERROR: unexpected tileType or tileCompression"; exit 1; }

echo "$HEADER" | jq -e '.minZoom <= .maxZoom and .maxZoom <= 16' > /dev/null \
  || { echo "ERROR: zoom range invalid or exceeds z16"; exit 1; }

echo "$HEADER" | jq -e '(.bounds[2] - .bounds[0]) > 0 and (.bounds[3] - .bounds[1]) > 0' > /dev/null \
  || { echo "ERROR: degenerate bounding box"; exit 1; }

echo "Validation passed." >&2

To inspect a remote archive without downloading it, replace "dist/world.pmtiles" with the HTTPS URL:

bash
pmtiles show "https://data.example.com/tiles/world.pmtiles" | jq '{tileType,tileCompression,minZoom,maxZoom,bounds}'

The CLI issues a single HTTP range request for the 127-byte header, then a second range request for the root directory section — typically under 5 KB total for the metadata fetch.

SVG: What pmtiles show Actually Reads

The diagram below shows which byte ranges the CLI fetches from the archive and what each section contains.

PMTiles archive sections read by pmtiles show The pmtiles show command issues two HTTP range requests: first for the 127-byte fixed header, then for the root directory + metadata sections. Tile data is never fetched during inspection. Header 127 bytes Root dir + Metadata variable (typically < 5 KB) Leaf dirs (optional) Tile data NOT fetched by pmtiles show Range request 1 bytes=0-126 Range request 2 bytes=dir_offset–meta_end (skipped) PMTiles archive — sections read by pmtiles show Two HTTP range requests fetch header + metadata; tile data is never loaded

Python Automation

For pipelines that already use Python, the pmtiles PyPI package exposes the same header fields via memory-mapped I/O, avoiding subprocess overhead:

bash
pip install pmtiles
python
from pmtiles.reader import Reader, MmapSource
import json, sys

def inspect_pmtiles(filepath: str) -> dict:
    """Parse PMTiles v3 header and metadata without loading the full archive."""
    try:
        with open(filepath, "r+b") as f:
            source = MmapSource(f)
            reader = Reader(source)

            header   = reader.header()
            metadata = json.loads(reader.metadata())   # raw JSON string → dict

            # Coordinates are E7-encoded in the binary header;
            # the Python reader exposes them already divided by 1e7.
            bounds = [
                header["min_lon_e7"] / 1e7,
                header["min_lat_e7"] / 1e7,
                header["max_lon_e7"] / 1e7,
                header["max_lat_e7"] / 1e7,
            ]

            return {
                "spec_version":     header["spec_version"],
                "tile_type":        header["tile_type"],        # integer enum
                "tile_compression": header["tile_compression"],  # integer enum
                "min_zoom":         header["min_zoom"],
                "max_zoom":         header["max_zoom"],
                "bounds":           bounds,
                "metadata_keys":    list(metadata.keys()),
                "vector_layers":    metadata.get("vector_layers", []),
            }
    except FileNotFoundError:
        print(f"ERROR: {filepath} not found", file=sys.stderr)
        sys.exit(1)
    except Exception as exc:
        print(f"ERROR: inspection failed — {exc}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else "dist/world.pmtiles"
    print(json.dumps(inspect_pmtiles(path), indent=2))

Call json.loads() on reader.metadata() before accessing nested keys — the reader returns a raw JSON string, not a parsed object. Accessing metadata["vector_layers"] directly on the string raises a TypeError that can be difficult to trace.

Interaction Effects

With zoom-level planning. The minZoom and maxZoom fields from pmtiles show should match the -Z and -z flags passed to Tippecanoe during generation. A mismatch indicates a packaging step silently dropped zoom levels. Cross-reference with calculating optimal max zoom for urban datasets when setting generation parameters.

With MBTiles migration. When migrating a SQLite-backed archive to PMTiles, run pmtiles show on the converted file and compare tileType and tileCompression against the source. The pmtiles convert tool preserves compression by default, but pipelines that re-encode tiles may silently change the compression codec — a discrepancy visible only via header inspection. The MBTiles architecture limits page covers what changes during conversion.

With CDN configuration. The bounds field from pmtiles show must fall within the geographic scope configured in your tile server or CDN routing rules. An archive covering a global dataset ([-180, -85.05, 180, 85.05]) served through a CDN origin restricted to a single region will silently return 404s for out-of-region tile requests. Inspect bounds before configuring cache rules.

Performance Impact

pmtiles show on a local file completes in under 50 ms for any archive size — it reads exactly 127 bytes plus the directory section (typically 512 bytes to several KB for dense global tilesets). The tool never loads tile payload data.

For remote archives over HTTPS, latency is dominated by TCP round trips, not data transfer volume. Two sequential range requests are made: one for the 127-byte header, one for the root directory plus metadata block. On a low-latency CDN origin this totals under 200 ms. On a high-latency origin (cross-region S3 with no CDN in front), expect 500–1500 ms — acceptable for CI/CD validation, not for runtime map rendering.

Inspecting tile count and coverage density requires pmtiles verify, which reads the full directory index and is O(n) in the number of tiles. For a global z14 tileset with ~340 million tiles, pmtiles verify can take several minutes.

Common Mistakes

1. Misreading tileType and tileCompression as strings

pmtiles show returns integer values for enum fields, not human-readable strings. A jq expression like .tileType == "MVT" silently evaluates to false; the correct assertion is .tileType == 1. Symptom: jq -e exits 0 but the pipeline passes an archive with the wrong tile type.

Corrective check:

bash
pmtiles show archive.pmtiles | jq -e '.tileType == 1' \
  || echo "ERROR: archive is not MVT (tileType must be 1)"

2. Inspecting metadata before the archive is fully written

Running pmtiles show on an in-progress pmtiles convert or tippecanoe output gives a partially written file. The header magic bytes may be valid but meta_length reads as 0, and reader.metadata() in Python returns an empty string. Always verify the process writing the archive has exited before inspecting.

Symptom: json.loads(reader.metadata()) raises json.JSONDecodeError: Expecting value: line 1 column 1 (char 0).

bash
# Wait for the generating process and inspect only on clean exit
tippecanoe -o dist/world.pmtiles input.geojson && pmtiles show dist/world.pmtiles | jq .

3. Confusing internalCompression with tileCompression

internalCompression (offset 97 in the binary header) controls how the directory index is compressed. tileCompression (offset 98) controls individual tile payloads. The pmtiles show JSON output labels these distinctly, but code that reads the raw binary header by offset position can swap them. A tile server configured to decompress tile payloads with zstd but receiving a gzip-compressed archive will emit decode errors on every tile request.

Corrective check against both fields:

bash
pmtiles show archive.pmtiles | jq '{internalCompression, tileCompression}'

Up: PMTiles Specification Deep Dive