MVT Encoding Internals: The Protobuf Tile Structure
When a tile renders wrong — features offset by a fixed amount, polygons filled where they should be hollow, an attribute that should read residential showing up as an integer — the fault is almost never in the map style. It is in the bytes. A Mapbox Vector Tile is a Protocol Buffers message with a deliberately compact geometry encoding, and once you can read that encoding by hand you can diagnose the entire class of “the data is right but the tile is wrong” bugs in minutes. This guide walks the MVT protobuf schema field by field, decodes the geometry command integers and their zigzag parameters, and shows how to verify extent and winding on a real tile so the output of your Tippecanoe generation pipeline is provably correct before it reaches the CDN.
Prerequisites
You do not need to write a protobuf parser to follow along — the tooling below expands the binary for you — but you should be comfortable with a few concepts before decoding.
| Requirement | Why it matters | Install / source |
|---|---|---|
| Protocol Buffers mental model | MVT is a .proto schema; fields are numeric tags with varint or length-delimited wire types |
protobuf.dev |
tippecanoe-decode |
Reverses a .pbf/.mvt back to GeoJSON-ish JSON, restoring real coordinates |
ships with Tippecanoe |
mapbox/vector-tile-js + pbf |
Node decoder that exposes raw loadGeometry() command output |
npm i @mapbox/vector-tile pbf |
mapbox_vector_tile (Python) |
mapbox_vector_tile.decode() returns a dict of layers/features |
pip install mapbox-vector-tile |
vector-tile-base (Python) |
Lower-level reader that exposes the raw command integers, not just decoded geometry | pip install vector-tile-base |
| A sample tile | Any 10/512/384.pbf extracted from an MBTiles/PMTiles archive or fetched from a server |
your own pipeline |
The canonical reference for every field discussed here is the Mapbox Vector Tile specification v2.1. Keep it open in a second tab; this page is the annotated, debugging-oriented companion to it.
Core Concept: The MVT Protobuf Schema
An MVT tile is a single Tile protobuf message. It contains a repeated Layer field, and nothing else at the top level — there is no tile-level coordinate, projection, or (z, x, y) address stored in the file. That addressing information lives in the container (MBTiles row keys, the PMTiles directory) or the URL, never in the tile body. This is why the same .pbf bytes are valid at any tile position: everything inside is expressed in a local grid.
Message fields
| Message | Field | Wire type | Meaning |
|---|---|---|---|
Tile |
layers |
repeated message | One entry per named layer |
Layer |
version |
uint32 | Encoder spec version; must be 2 for v2.1 |
Layer |
name |
string | Layer identifier, e.g. "roads" — this is the style source-layer |
Layer |
extent |
uint32 | Grid resolution of the tile, default 4096 |
Layer |
keys |
repeated string | Deduplicated attribute-key dictionary |
Layer |
values |
repeated Value | Deduplicated, typed attribute-value dictionary |
Layer |
features |
repeated message | The geometry-bearing rows |
Feature |
id |
optional uint64 | Feature id; not guaranteed unique across layers |
Feature |
tags |
repeated uint32 | Alternating key-index / value-index pairs |
Feature |
type |
enum GeomType | UNKNOWN=0, POINT=1, LINESTRING=2, POLYGON=3 |
Feature |
geometry |
repeated uint32 | The command-and-parameter integer stream |
Two design choices drive the whole format. First, attributes are dictionary-encoded per layer: a feature never stores the string "highway" or "residential" inline. It stores integer indices into the layer’s keys[] and values[] arrays via its tags[] list. A feature with tags = [0, 1, 3, 0] means key[0] = value[1] and key[3] = value[0]. This deduplication is the same mechanism that makes attribute filtering so effective at shrinking tiles — dropping a key removes it from the table, not just from one feature. Second, geometry is a delta-encoded integer stream, not a list of coordinates.
The geometry command encoding
Each element of the geometry array is either a command integer or a parameter integer. A command integer packs a command id and a repeat count into one number:
CommandInteger = (id & 0x7) | (count << 3)
The low 3 bits are the command id; the remaining bits are how many times the command repeats before the next command integer appears.
| Command | id | Parameters each | Effect |
|---|---|---|---|
MoveTo |
1 | 2 (dx, dy) |
Move cursor, begin a new point / ring / line |
LineTo |
2 | 2 (dx, dy) |
Draw to a new point, extending the geometry |
ClosePath |
7 | 0 | Close the current polygon ring (no parameters) |
Parameter integers are zigzag-encoded so that small negative deltas stay small. The transform is:
encoded = (n << 1) ^ (n >> 31) # encode a signed 32-bit int
n = (encoded >> 1) ^ (-(encoded & 1)) # decode back to signed
So 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4. Every dx/dy is relative to the running cursor, not absolute. The cursor starts at (0, 0) at the top-left of the tile and accumulates as you walk the stream. This relativity is exactly what a naive decoder gets wrong: forget to keep the cursor and every vertex after the first lands in the wrong place.
Worked example — a single MoveTo:
geometry = [ 9, 6, 12 ]
9 = (1 & 0x7) | (1 << 3) → command MoveTo, count 1
6 → zigzag decode → 3 (dx = +3)
12 → zigzag decode → 6 (dy = +6)
cursor: (0,0) → (3,6)
A LineTo with two points would begin with (2 & 0x7) | (2 << 3) = 18, followed by four parameter integers. A ClosePath is simply (7 & 0x7) | (1 << 3) = 15 with no parameters. A typical polygon ring therefore reads MoveTo(1) … LineTo(n) … ClosePath and the exterior ring must wind clockwise in tile pixel space.
The count field is what makes the stream compact: a linestring with 500 vertices is one MoveTo (count 1) followed by a single LineTo command integer with count 499, then 998 parameter integers — not 500 separate command tags. Multi-part geometries repeat the pattern: a MultiLineString is several MoveTo/LineTo runs back to back, and a polygon with holes is one MoveTo/LineTo/ClosePath sequence per ring. The cursor is never reset between rings or parts — it carries across the entire feature — which is why the first MoveTo of a second ring is itself a delta from the last vertex of the previous ring, not an absolute position. Decoders that reset the cursor per ring produce geometry that is correct for the first ring and progressively displaced thereafter.
Step-by-Step: Reading a Real Tile
Step 1 — Decode a tile with tippecanoe-decode
The fastest way to see a tile’s structure with real-world coordinates restored is tippecanoe-decode. Give it the tile plus its z x y so it can map the local grid back to longitude/latitude.
# Pull one tile out of a PMTiles archive, then decode it
pmtiles tile roads.pmtiles 10 512 384 > tile.pbf
# Expand to JSON; the z/x/y let the decoder restore lon/lat
tippecanoe-decode tile.pbf 10 512 384 | head -40
Verify: the output lists each layer with a "features" array; the layer "name" matches what your MapLibre style expects as source-layer.
Step 2 — Inspect layers, keys and values
To see the raw dictionaries rather than reconstructed GeoJSON, read the layer directly. The Node decoder exposes exactly the protobuf fields:
import { VectorTile } from '@mapbox/vector-tile';
import Protobuf from 'pbf';
import { readFileSync } from 'node:fs';
const tile = new VectorTile(new Protobuf(readFileSync('tile.pbf')));
for (const name of Object.keys(tile.layers)) {
const layer = tile.layers[name];
console.log(name, 'extent', layer.extent, 'features', layer.length);
const f = layer.feature(0);
console.log(' type', f.type, 'id', f.id);
console.log(' properties', f.properties); // keys[]/values[] already joined
}
Verify: layer.extent is 4096 (or whatever you set with --full-detail/--extent), and f.properties contains the attribute keys you expect. If a key you filtered on is missing here, no downstream layer filter will ever match it.
Step 3 — Hand-trace a command integer
To confirm the geometry stream itself is well-formed, drop below the convenience decoders to the raw integers. Python’s vector-tile-base returns geometry without pre-applying the deltas, which is ideal for a trace:
import vector_tile_base
with open("tile.pbf", "rb") as fh:
vt = vector_tile_base.VectorTile(fh.read())
layer = vt.layers[0]
feat = layer.features[0]
print("type:", feat.type) # 'point' | 'line_string' | 'polygon'
print("extent:", layer.extent) # 4096
print("geometry:", feat.get_geometry()) # nested lists of [x, y] in tile coords
If you want to see the encoded command integers before any decoding, read them with a tiny manual walker so you can eyeball the (id, count) split and the zigzag params:
def walk_commands(raw): # raw: list[int] from the protobuf geometry field
i, cursor = 0, (0, 0)
while i < len(raw):
cmd_int = raw[i]; i += 1
cmd_id, count = cmd_int & 0x7, cmd_int >> 3
name = {1: "MoveTo", 2: "LineTo", 7: "ClosePath"}[cmd_id]
for _ in range(count):
if cmd_id == 7: # ClosePath has no parameters
print(f"{name}")
continue
dx = (raw[i] >> 1) ^ -(raw[i] & 1); i += 1
dy = (raw[i] >> 1) ^ -(raw[i] & 1); i += 1
cursor = (cursor[0] + dx, cursor[1] + dy)
print(f"{name} d=({dx:+},{dy:+}) -> {cursor}")
Verify: every reconstructed cursor position lands inside 0 .. extent. A value like 4210 on a 4096 tile is the buffer region — expected up to the tile’s simplification buffer — but a value in the thousands negative or tens of thousands positive signals a decode bug or an extent mismatch.
Step 4 — Verify extent and winding
The exterior ring of a polygon must be wound clockwise and interior rings (holes) counter-clockwise, measured in tile coordinate space where y increases downward. The signed area of the ring tells you which you have:
def signed_area(ring): # ring: list of (x, y) tile coords
return sum(x0 * y1 - x1 * y0
for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1])) / 2
# In MVT tile space (y down), exterior rings have NEGATIVE signed area.
Verify: exterior rings return negative signed area; holes return positive. If an exterior ring comes back positive, the renderer will treat it as a hole and the fill inverts — the classic “filled where it should be hollow” symptom.
Optimization Knobs
The encoding exposes a handful of levers that trade tile size against fidelity. Most are set at generation time in Tippecanoe; understanding what they change in the bytes tells you when to reach for each.
| Knob | Lower value | Higher value | What changes in the bytes |
|---|---|---|---|
extent (grid) |
512 |
4096 (default) |
Coordinate precision per tile; lower extent = coarser grid, fewer param bits, smaller geometry but visible stair-stepping when overzoomed |
| Attribute keys retained | few | many | Length of keys[]/values[] tables and tags[] per feature; the single biggest lever on attribute-heavy tiles |
| Value type coercion | typed | all strings | values[] stores typed scalars; mixed source types force string coercion and defeat numeric dedup |
| Feature ordering | arbitrary | spatially sorted | Sorted features produce smaller cumulative deltas in the geometry stream, mildly improving gzip ratio |
| Coordinate quantization | aggressive | full detail | Rounding vertices to the grid collapses near-duplicate points, shrinking LineTo runs |
The extent trade-off is the one most teams get wrong. 4096 is the default because a tile is 256 CSS pixels rendered at up to 512 device pixels; 4096 gives roughly 8–16 sub-pixel steps, enough that geometry stays crisp when the client overzooms one level. Dropping to 512 saves a few percent of geometry bytes but makes overzoomed lines visibly jagged. Reach for a lower extent only for coarse thematic layers (choropleth boundaries) where sub-pixel precision is irrelevant.
Integration with Adjacent Pipeline Stages
MVT is the interchange format that sits between generation and rendering, so its fields are contracts that two other stages depend on.
Tippecanoe emits it. Every flag you pass to Tippecanoe ultimately manifests as bytes described on this page: --layer sets Layer.name, --include/--exclude prune the keys[] table, and simplification flags thin the LineTo runs. When you decode a tile and something looks off, the fix is almost always a generation flag, not a post-process. The full flag inventory lives in the Tippecanoe CLI fundamentals section.
MapLibre decodes it. The browser runs the exact reverse of Step 3 in a Web Worker for every tile, applies the extent to scale geometry into screen space, and joins tags[] against keys[]/values[] to expose feature.properties to style expressions. The critical handshake is the layer name: Layer.name in the tile must equal the source-layer value in the MapLibre style JSON. A mismatch renders nothing, with no error — the style simply finds no matching layer to paint.
The coordinate story continues upward. The local 0 .. extent grid on this page is only half the coordinate picture; how that grid maps to Web Mercator and then to longitude/latitude is covered in tile coordinate systems, which explains why nothing in the tile body records world coordinates and how the (z, x, y) address supplies them.
Troubleshooting
1. Polygons render filled where they should have holes
Symptom: lakes, courtyards, and building light-wells appear solid.
Diagnosis: decode the feature and check exterior-ring winding with the signed_area() helper above. If an exterior ring returns positive area (in y-down tile space), its winding is reversed.
tippecanoe-decode tile.pbf 10 512 384 | python3 -c "import sys,json; d=json.load(sys.stdin); print('inspect ring order')"
Fix: regenerate with valid input geometry — run ogr2ogr -makevalid or shapely.make_valid() upstream so ring orientation is normalized before Tippecanoe encodes it. Tippecanoe follows the winding of the input; it does not silently correct reversed rings.
2. Every feature is offset by a constant amount
Symptom: the whole layer is shifted by the same dx/dy across the tile.
Diagnosis: this is an extent mismatch. The tile was encoded at one extent (say 4096) but a custom decoder assumes another (say 256), so the scale factor from tile grid to screen is wrong.
console.log(layer.extent); // must match the divisor your renderer uses
Fix: always read Layer.extent from the tile rather than hard-coding 4096. MapLibre does this automatically; custom parsers frequently do not.
3. A layer never appears in the map
Symptom: the style loads, the tile is 200 OK, but the layer is blank.
Diagnosis: the style’s source-layer does not match Layer.name. Decode and list names:
tippecanoe-decode tile.pbf 10 512 384 | grep -o '"name":"[^"]*"' | sort -u
Fix: align the two. If you renamed a layer during generation with --layer, update the style’s source-layer. This coupling is exactly what layer filter synchronization exists to keep in lockstep across multi-source styles.
4. Geometry looks scrambled in a custom parser
Symptom: vertices land in random positions; lines zig-zag across the tile.
Diagnosis: the parser is treating parameter integers as absolute coordinates instead of accumulating them onto the cursor, or it is skipping the zigzag decode. Re-run the walk_commands() trace: if the first point is correct but everything after drifts, the cursor is not being carried forward.
Fix: apply zigzag decode to every parameter, then add each (dx, dy) to the running cursor before recording a vertex. Unquantized or non-integer coordinates reaching the encoder produce the same symptom from the other direction — ensure input coordinates are finite and within the tile before generation.
Further Reading
Decoding MVT Tiles to GeoJSON in Python — a complete, copy-pasteable Python workflow that fetches a .pbf over HTTP, handles gzip, decodes it with mapbox_vector_tile, rescales the extent-4096 coordinates back to lon/lat, and dumps standards-compliant GeoJSON for CI assertions and inspection.
Parent: Vector Tile Architecture & Format Fundamentals
Related
- Tile Coordinate Systems & the Slippy Map Grid — how the local
0..extenttile grid maps to Web Mercator and the(z, x, y)address that MVT bytes deliberately omit. - PMTiles Specification Deep Dive — the archive format that stores these
.pbftiles and maps(z, x, y)to byte offsets for range-request delivery. - Tippecanoe CLI Fundamentals — the generation flags that determine every field discussed here, from
Layer.nameto thekeys[]table and simplification of the geometry stream.