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.


MVT protobuf structure from Tile down to geometry commands Four boxes left to right connected by arrows: Tile message holding layers, Layer holding name extent 4096 keys and values, Feature holding id tags type and geometry, and the geometry command stream of MoveTo LineTo ClosePath. Tile protobuf message layers[] 1 Layer name, version, extent 4096 keys[] values[] 2 Feature id, tags[], type geometry[] 3 Geometry command + params MoveTo LineTo ClosePath

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:

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

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

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

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

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

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

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

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

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

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

bash
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

Next reading Decoding MVT Tiles to GeoJSON in Python