Tile Metadata & TileJSON for Vector Tilesets
A tileset is not just tiles. It also has to say what it contains — which layers, which attributes, which zoom levels, which corner of the world — because nothing in a .mvt tile carries that information and a client cannot discover it by fetching one. That self-description is the metadata, and when it is served as its own document it is TileJSON. This topic covers what belongs in it, how to derive it from the archive rather than hand-maintain it, and how to check that it still matches the tiles it describes.
Prerequisites
| Requirement | Why it matters here |
|---|---|
A built .mbtiles or .pmtiles archive |
Every field below is read out of it rather than invented |
pmtiles CLI or sqlite3 |
To read the metadata section or table |
jq |
The generation and assertion steps are jq expressions |
| A style that names this tileset | The consumer whose expectations the metadata must satisfy |
Core Concept: The Fields That Actually Matter
TileJSON defines many fields. In a vector tile pipeline exactly five of them do real work, and the rest are documentation.
| Field | Type | What breaks without it |
|---|---|---|
tiles |
array of URL templates | The client has no address to fetch from |
vector_layers |
array of layer descriptors | MapLibre cannot resolve any source-layer; every layer renders empty |
minzoom / maxzoom |
integer | The client requests zooms that do not exist, or never requests ones that do |
bounds |
[west, south, east, north] |
fitBounds and initial camera placement have nothing to work from |
attribution |
HTML string | A licensing obligation goes unmet |
vector_layers is the one that carries the contract. Each entry names a layer and describes its fields:
{
"vector_layers": [
{
"id": "roads",
"description": "Road centrelines from OpenStreetMap",
"minzoom": 5,
"maxzoom": 14,
"fields": {
"name": "String",
"highway": "String",
"oneway": "Boolean",
"lanes": "Number"
}
}
]
}
That fields object is exactly the schema a style validation workflow checks expressions against. When it is accurate, a renamed attribute is caught in CI; when it is stale or absent, the same rename reaches a browser as a layer that quietly renders in its fallback colour.
Where the Metadata Physically Lives
The same information sits in three places, with three different access patterns, and knowing which one a given tool reads saves a lot of confusion.
Inside an MBTiles file, in the metadata table as name/value rows. The json row holds a JSON string that itself contains vector_layers — a nested encoding that surprises people the first time they query it:
sqlite3 basemap.mbtiles \
"SELECT value FROM metadata WHERE name='json';" | jq '.vector_layers[].id'
Inside a PMTiles archive, in the metadata section, whose offset the header carries. pmtiles show reads it without touching the tile data:
pmtiles show basemap.pmtiles | jq '.vector_layers[] | {id, fields}'
As a standalone TileJSON document served over HTTP, which is what a tile server emits at its TileJSON endpoint and what a static deployment must generate and upload itself. This is the only one a browser can read directly, and for a static PMTiles deployment it is optional — the PMTiles client reads the archive’s own metadata through a range request instead.
Step-by-Step: Generating TileJSON From an Archive
Step 1 — Read what the build actually produced
Never start from a template. Start from the archive, because the archive is the only artefact that cannot be wrong about itself:
pmtiles show tiles/v43/basemap.pmtiles > /tmp/archive-meta.json
jq '{minzoom: .min_zoom, maxzoom: .max_zoom, bounds: .bounds}' /tmp/archive-meta.json
Verify at this point that the zoom range matches what the build was asked for. A -z14 build reporting a max zoom of 12 means Tippecanoe stopped early, usually because the input covered a smaller area than expected.
Step 2 — Assemble the document
jq -n --slurpfile m /tmp/archive-meta.json '{
tilejson: "3.0.0",
name: "Basemap v43",
scheme: "xyz",
tiles: ["https://tiles.example.com/v43/basemap/{z}/{x}/{y}.mvt"],
minzoom: $m[0].min_zoom,
maxzoom: $m[0].max_zoom,
bounds: $m[0].bounds,
center: [
(($m[0].bounds[0] + $m[0].bounds[2]) / 2),
(($m[0].bounds[1] + $m[0].bounds[3]) / 2),
$m[0].min_zoom
],
attribution: "© OpenStreetMap contributors",
vector_layers: $m[0].vector_layers
}' > tiles/v43/basemap.json
Every value except the URL, the name and the attribution comes from the archive. Those three are the only things a human should be supplying.
Step 3 — Check it against the tiles it describes
A TileJSON document can be perfectly valid and describe a different tileset. The check that matters is that each declared layer actually appears in a tile:
# Pick a tile in the middle of the declared bounds and list its real layers
pmtiles tile tiles/v43/basemap.pmtiles 12 2048 1362 \
| gzip -d \
| npx @mapbox/vector-tile-cli list \
| sort > /tmp/actual-layers.txt
jq -r '.vector_layers[].id' tiles/v43/basemap.json | sort > /tmp/declared-layers.txt
diff /tmp/declared-layers.txt /tmp/actual-layers.txt && echo "layers agree"
A layer declared but absent is the failure mode that produces an empty map. A layer present but undeclared is harmless to the renderer and invisible to the validator — which makes it the more insidious of the two, because a style can come to depend on it and no check will notice.
Step 4 — Publish it beside the tileset
The TileJSON document is versioned with the tiles it describes, not separately. It lives under the same prefix, and it inherits the same immutable cache headers, because a document describing an immutable tileset is itself immutable:
aws s3 cp tiles/v43/basemap.json s3://tiles-bucket/v43/basemap.json \
--content-type "application/json" \
--cache-control "public, max-age=31536000, immutable"
The style then points at https://tiles.example.com/v43/basemap.json and gets everything it needs in one fetch. Rotating to v44 is the same versioned prefix rotation as the tiles, for the same reason.
Optimization Knobs
| Knob | Conservative | Aggressive | Trade-off |
|---|---|---|---|
fields detail |
Every attribute with its type | Only attributes the style reads | A narrow list makes the validator weaker but the document smaller |
Per-layer minzoom |
Omit; inherit the tileset range | Declare per layer | Per-layer ranges let a client skip requests, at the cost of a document that must be regenerated on every threshold change |
center zoom |
The tileset’s minzoom |
A useful default view | Purely a UX choice; nothing validates it |
| Serving TileJSON at all | Always, beside the archive | Omit for pure PMTiles deployments | The PMTiles client reads the archive’s metadata directly, so the document is redundant — but it is the only thing a non-PMTiles consumer can read |
Integration With Adjacent Pipeline Stages
Upstream, the tile build decides everything in the document. Every field is a consequence of --layer, --include, -z and -Z, which is why generating rather than authoring the metadata is what keeps it honest.
Downstream, the style reads it and the validator checks against it. A style validation workflow that uses the TileJSON as its schema fixture gets freshness for free, because the fixture is regenerated by the build that produced the tiles.
Sideways, anything that consumes the tileset without a style — an analytics job, another team’s overlay, a desktop GIS session — reads the same document. This is the argument for describing every attribute rather than only the ones the current style happens to use.
Troubleshooting
vector_layers is missing entirely
Symptom: Every layer in the style renders empty; the console shows no errors.
Cause: Tippecanoe writes vector_layers into the archive automatically, so an archive without it has usually been rebuilt by a tool that did not — a hand-written SQLite assembly, or a tile-join invocation that lost the metadata.
Fix: Regenerate the metadata from a tile scan, or rebuild with tile-join passing --name and letting it carry the source metadata forward:
sqlite3 out.mbtiles "SELECT name FROM metadata;" | grep -q '^json$' \
|| echo "no json metadata row — vector_layers is missing"
Declared field types disagree with the tiles
Symptom: An interpolate expression falls through to its fallback for every feature.
Cause: The document declares Number and the tiles encode a string, usually because the source column held mixed types and Tippecanoe coerced the whole column.
Fix: Correct the type at the source and rebuild — see dynamic attribute mapping for why repairing this in an expression is the wrong layer to fix it in.
Bounds cover the whole world when the data is one city
Symptom: fitBounds opens on the entire globe.
Cause: Either a single stray feature at 0, 0 — the classic result of a null geometry coerced to the origin — or bounds hand-written as [-180, -85, 180, 85] and never revisited.
Fix: Find the outliers before trusting the bounds:
ogrinfo -al -so source.geojson | grep Extent
# Extent: (-0.51, 51.28) - (0.33, 51.69) <- a plausible city
# Extent: (-180.0, -85.05) - (180.0, 85.05) <- a stray feature at the origin
Metadata as a Migration Tool
Because the metadata describes the tileset rather than the data, it is also where a schema change is announced — and treating it that way turns an awkward coordination problem into an ordinary one.
Consider renaming an attribute from pop to population. The tile build can carry both for one release, and the metadata then declares both, which means a validator checking a style against the fixture accepts either name. Styles migrate at their own pace. When the last one has moved, the build drops pop, the metadata stops declaring it, and the validator starts rejecting any style that still asks for it — automatically, without anyone remembering to enforce the deadline.
The same shape works for a layer rename, a zoom range extension, or a type change carried as two differently named attributes. In each case the metadata is what makes the transitional state legible: a consumer can ask what the tileset currently supports rather than reading a build script or a changelog.
What makes this work is that the metadata is generated, not written. A hand-maintained document describes intentions, and intentions are exactly what a migration cannot rely on. A generated one describes the archive, so “both names are available” is a fact a machine can check rather than a promise someone made in a pull request.
The Cost of Getting It Wrong
Every field in this topic is small, and the failure modes are disproportionate to that size, which is worth stating explicitly because it is why the assertions above are worth the trouble.
A wrong maxzoom produces 404s for a whole band of zoom levels, visible only to readers who zoom in far enough. A missing vector_layers blanks every layer on the map with no error anywhere. A bounds array covering the world makes the initial camera useless. An absent attribution is a licence violation that no test will ever catch.
None of these is a subtle bug requiring investigation. All of them are a single wrong value in a document a build could have generated correctly for free — which is the argument for generating it, asserting it, and never editing it by hand.
One Document, Several Readers
A last point worth making explicit: the metadata is read by more consumers than the map. A validator uses it as a schema fixture, a desktop GIS session uses it to label the tileset, an analytics job uses it to learn which attributes exist, and a colleague uses it to find out what a tileset actually contains without opening it. Writing it for the renderer alone produces a document that is correct and unhelpful to four of those five.
FAQ
Should TileJSON be written by hand?
No. Every field except the URL, the name and the attribution is derivable from the archive, and a hand-maintained document describes intentions rather than the tileset that exists.
Does a PMTiles deployment need a TileJSON document?
Not for MapLibre, which reads the archive’s own metadata through a range request. It is still worth publishing for every other consumer — desktop GIS, validators, another team’s job — that speaks TileJSON and nothing else.
What is vector_layers actually used for?
Resolving source-layer at runtime, and serving as the schema fixture a style validator checks expressions against. Without it every layer renders empty with no error anywhere.
How do I keep the metadata from drifting?
Generate it in the same build that produces the tiles, and assert it in the publish gate. Anything regenerated separately eventually describes a tileset that no longer exists.
In-Depth Guides
Writing Valid TileJSON for MapLibre Sources — the minimum document MapLibre will accept, the fields it silently ignores, and how a source defined inline differs from one loaded from a URL.
Fixing Missing vector_layers in MBTiles Metadata — how the row goes missing, how to rebuild it from a tile scan, and how to stop tile-join dropping it.
Computing Bounds and Center From a Tileset — deriving a real extent from the tiles that exist rather than from the source data, and why the two differ.
Related
- MBTiles Architecture & Limits — where the
metadatatable sits in the SQLite schema and which keys are mandatory. - PMTiles Specification Deep Dive — the metadata section, its offset in the header, and why reading it costs one range request.
- MapLibre GL JSON Structure — how a style’s
sourcesobject consumes the document this topic produces. - Layer Filter Synchronization — using
vector_layersas the schema a filter check runs against. - Vector Tile Architecture & Format Fundamentals — the parent section, and where this topic sits in the full pipeline.