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.

Metadata is derived from the build, not written by handThe tile build records layer names, attributes and zoom range; those are written into the archive metadata; TileJSON is generated from the archive; and the style consumes the TileJSON.WHERE METADATA COMES FROMTile buildlayer nameszoom rangeArchive metadatavector_layersboundsTileJSONgeneratednever edited by handStyle sourceurl points here
Every hand-maintained copy of this information drifts. The only stable arrangement is one where each step reads from the one before it.

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:

json
{
  "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.

Which consumer needs which fieldFour consumers of a TileJSON document — the renderer, the style validator, the camera and the legal notice — and the fields each depends on.WHO READS WHATRendererMapLibretiles, vector_layers, minzoom, maxzoom.Without vector_layers it cannot match asource-layer to anything.Style validatorCIvector_layers and its fields object — thisis the schema fixture expressions arechecked against.CamerafitBoundsbounds and center. Wrong here and the mapopens on the wrong continent.Attribution controlUIattribution. The only field with a legalconsequence when it is missing.
The fields are not equally important to everyone. vector_layers is the only one every consumer touches, which is why it is the one worth asserting hardest.

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:

bash
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:

bash
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:

bash
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

bash
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:

bash
# 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.

What CI should assert about a TileJSON documentSix assertions covering zoom range agreement, layer presence, field types, bounds sanity, attribution and URL reachability.CI ASSERTIONSminzoom and maxzoom equal the archive's own valuesnot the values the build script intendedEvery declared layer appears in a sampled tilethe check that catches a renameField types match what the tiles encodea Number declared as String breaks interpolate expressionsbounds intersect the expected region0,0,0,0 means every feature was droppedattribution is present and non-emptycheap to assert, awkward to discover missingThe tiles URL template resolves to a real tilea typo here passes every structural check
All six are one-line jq or curl expressions. Together they are the difference between publishing a tileset and publishing a described tileset.

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:

bash
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:

bash
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:

bash
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.

Next reading Computing Bounds and Center From a Tileset Next reading Fixing Missing vector_layers in MBTiles Metadata Next reading Writing Valid TileJSON for MapLibre Sources