Fixing Missing vector_layers in MBTiles Metadata
If a tileset renders nothing and the console is clean, read the json row of its metadata table before reading anything else. A missing or empty vector_layers array means no style layer can resolve its source-layer, and every layer in the map renders empty without a single error being raised.
When to Use This
The symptom is specific: tiles return 200, they decode and contain features, and the map is blank. Three situations produce it.
An MBTiles file assembled by custom code — a Python writer inserting rows into tiles directly — which wrote the mandatory metadata keys and not the json row. A tile-join merge that lost the metadata because neither input carried it forward. Or a tileset converted between formats by a tool that copied tiles without copying the description of them.
Specification Detail: Where the Array Lives
MBTiles stores metadata as name/value rows, and vector_layers is not a row of its own. It lives inside a JSON string stored under the name json:
| Metadata row | Value | Required |
|---|---|---|
name |
Human-readable tileset name | Yes |
format |
pbf for vector tiles |
Yes |
bounds |
west,south,east,north |
Recommended |
minzoom / maxzoom |
Integers as strings | Recommended |
json |
A JSON string containing vector_layers |
Effectively required for vector tiles |
# The nested encoding, in one command
sqlite3 basemap.mbtiles "SELECT value FROM metadata WHERE name='json';" \
| jq '.vector_layers | length'
# 0 or an error here is the bug
Production Command
Rebuild the array by scanning tiles. The scan reads a sample across the zoom range, unions the layer names and attribute keys it finds, and writes the result back:
import gzip
import json
import sqlite3
import mapbox_vector_tile
TYPE_OF = {str: "String", int: "Number", float: "Number", bool: "Boolean"}
def rebuild_vector_layers(path: str, sample_per_zoom: int = 40) -> dict:
conn = sqlite3.connect(path)
zooms = [z for (z,) in conn.execute(
"SELECT DISTINCT zoom_level FROM tiles ORDER BY zoom_level")]
layers: dict[str, dict] = {}
for z in zooms:
rows = conn.execute(
"SELECT tile_data FROM tiles WHERE zoom_level = ? "
"ORDER BY length(tile_data) DESC LIMIT ?", (z, sample_per_zoom))
for (blob,) in rows:
raw = gzip.decompress(blob) if blob[:2] == b"\x1f\x8b" else blob
for name, layer in mapbox_vector_tile.decode(raw).items():
entry = layers.setdefault(
name, {"id": name, "minzoom": z, "maxzoom": z, "fields": {}})
entry["minzoom"] = min(entry["minzoom"], z)
entry["maxzoom"] = max(entry["maxzoom"], z)
for feature in layer["features"]:
for key, value in feature["properties"].items():
entry["fields"].setdefault(key, TYPE_OF.get(type(value), "String"))
conn.close()
return {"vector_layers": sorted(layers.values(), key=lambda d: d["id"])}
def write_metadata(path: str, doc: dict) -> None:
conn = sqlite3.connect(path)
conn.execute(
"INSERT INTO metadata (name, value) VALUES ('json', ?) "
"ON CONFLICT(name) DO UPDATE SET value = excluded.value",
(json.dumps(doc, separators=(",", ":")),))
conn.commit()
conn.close()
doc = rebuild_vector_layers("basemap.mbtiles")
print(json.dumps(doc, indent=2))
write_metadata("basemap.mbtiles", doc)
Sampling the largest tiles per zoom rather than random ones is deliberate: the biggest tiles carry the most layers and the widest attribute coverage, so a small sample finds nearly everything.
Interaction Effects
With tile-join. Merging two archives produces one output whose metadata is taken from the inputs, and the result is only as good as what they carried. Pass --name and --attribution explicitly, and verify the json row after every merge rather than assuming it survived — see merging MBTiles files with tile-join.
With conversion to PMTiles. pmtiles convert copies the metadata it finds. Converting an archive with a missing json row produces a PMTiles archive with the same gap, and it is markedly more awkward to patch after conversion — fix it in the MBTiles first.
With attribute filtering. The rebuilt fields object describes what is in the tiles, which is what a validator should check against. If it lists attributes the style does not use, that is information, not a defect; if it omits one the style reads, the attribute filtering step removed it and the style is the thing to fix.
Performance Impact
The scan is I/O bound and reads only the sampled tiles — forty per zoom across fifteen zooms is six hundred tiles, a few tens of megabytes, and a handful of seconds even on a large archive. Raising the sample improves attribute coverage on layers with rare optional fields and changes nothing about layer discovery, which saturates almost immediately.
Running the scan as a build step rather than a rescue operation is cheap enough to be worth it: a few seconds per build buys a guarantee that the description matches the tiles.
Preventing It Rather Than Repairing It
The repair above is straightforward, and it is still worth arranging never to need it. Three habits remove the failure entirely.
Let Tippecanoe write the archive. It populates the json row correctly on every build, including the per-layer fields object with types inferred from the data. Almost every instance of this problem comes from an archive assembled by something else — a custom writer, an ad-hoc merge, a format conversion.
Assert the row exists in the build gate. One line, and it fails the build rather than the map:
COUNT=$(sqlite3 out.mbtiles \
"SELECT value FROM metadata WHERE name='json';" | jq '.vector_layers | length')
[ "${COUNT:-0}" -gt 0 ] || { echo "vector_layers missing or empty"; exit 1; }
Treat the archive as immutable after the gate. Post-build mutation — a script that “just fixes the bounds”, a manual sqlite3 session — is where correct metadata gets replaced by plausible metadata. If something needs changing, change the build and produce a new archive under a new version prefix.
What the Rebuilt Array Cannot Tell You
The scan reconstructs what is in the tiles, which is exactly right for validation and slightly wrong for documentation. Two gaps are worth knowing about.
Attributes that exist only on rare features may be missed by a sample. Layer discovery saturates within a handful of tiles, but a field appearing on one feature in ten thousand can be absent from the reconstructed fields object — and a style reading it would then fail validation against a fixture that is itself incomplete. Raising the sample per zoom is the mitigation; being aware that the fixture is a lower bound is the honest framing.
Human-facing descriptions cannot be recovered at all. The original description on each layer, the attribution string, the intended name — none of these are in the tiles. If they were lost with the metadata row, they have to come from the build configuration, which is another argument for generating the whole document from the build rather than patching an archive afterwards.
Common Mistakes
Writing the array as its own metadata row. INSERT INTO metadata VALUES ('vector_layers', ...) looks reasonable and is ignored by every reader. It must be nested inside the json row’s value.
Rebuilding from the source data instead of the tiles. The source has attributes the build dropped and features at zooms the build never generated. A document derived from it describes a tileset that does not exist.
Forgetting the zoom ranges. An entry without minzoom and maxzoom is still usable, but a validator cannot then tell that a style drawing buildings at z8 is asking for something the layer does not cover at that zoom.
FAQ
How does the array go missing in the first place?
Almost always through an archive that Tippecanoe did not write: a custom SQLite writer, a merge that lost the metadata, or a format conversion that copied tiles without their description.
Can I rebuild it exactly?
Structurally, yes — layer names, attribute names, inferred types and zoom ranges all come out of the tiles. Human-written descriptions and attribution do not, because they were never in the tiles.
How many tiles does the scan need?
Layer discovery saturates within a handful of tiles. Forty of the largest per zoom is generous and takes seconds, and raising it mainly improves coverage of rare optional attributes.
How do I stop it happening again?
Assert the row exists in the build gate. One line of jq against the metadata, and the build fails instead of the map.
Related
- Tile Metadata & TileJSON — the parent topic and what a complete metadata document contains.
- MBTiles Architecture & Limits — the SQLite schema this page writes into, and the mandatory metadata keys.
- Merging MBTiles Files with tile-join — the operation that most often loses the row.
- Decoding MVT Tiles to GeoJSON in Python — the decoder the scan above depends on.