MVT Geometry Command Encoding Explained
A feature’s geometry in an MVT tile is a flat array of unsigned 32-bit integers, not a list of coordinates. Reading it requires three rules: a command integer packs an id and a count, parameters are zigzag-encoded deltas rather than absolute positions, and a single cursor persists across every command in the feature. Miss any one and the geometry decodes into noise that still looks structurally valid.
When to Use This
You need this when writing or debugging a decoder, when geometry arrives scrambled or uniformly offset, or when a tile’s byte budget has to be attributed to something more specific than “geometry”. If you only need coordinates out of a tile, use a library — decoding MVT tiles in Python does it in a dozen lines and gets all three rules right.
Specification Detail
The command integer
| Bits | Meaning | Values |
|---|---|---|
| 0–2 | Command id | 1 MoveTo, 2 LineTo, 7 ClosePath |
| 3–31 | Repeat count | How many times to apply the command |
command_id = value & 0x7
count = value >> 3
value = (count << 3) | command_id
A LineTo repeated 40 times is the single integer (40 << 3) | 2 = 322, followed by 80 parameter integers.
| Command | id | Parameters consumed | Effect |
|---|---|---|---|
MoveTo |
1 | 2 × count |
Move the cursor; begin a new ring or part |
LineTo |
2 | 2 × count |
Extend the current part to each new position |
ClosePath |
7 | 0 |
Close the current ring; cursor unchanged |
ClosePath always carries a count of 1 and consumes no parameters. A decoder that unconditionally skips 2 × count integers after every command desynchronises on the first polygon and produces garbage from there on — the single most common decoder bug.
Zigzag encoding
Parameters are signed deltas stored as unsigned integers, mapped so that small magnitudes of either sign stay small:
encoded = (value << 1) ^ (value >> 31) # signed 32-bit arithmetic shift
decoded = (encoded >> 1) ^ (-(encoded & 1))
So 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4. Without it, a delta of -1 would be 0xFFFFFFFF and cost five bytes as a varint instead of one.
Production Command: A Complete Decoder
def zigzag(n: int) -> int:
return (n >> 1) ^ (-(n & 1))
def decode_geometry(geom: list[int], extent: int = 4096) -> list[list[tuple[int, int]]]:
"""Return a list of parts, each a list of (x, y) in tile-local units."""
parts: list[list[tuple[int, int]]] = []
current: list[tuple[int, int]] = []
cx = cy = 0 # the cursor — never reset between commands
i = 0
while i < len(geom):
value = geom[i]
command, count = value & 0x7, value >> 3
i += 1
if command == 1: # MoveTo
for _ in range(count):
cx += zigzag(geom[i]); cy += zigzag(geom[i + 1]); i += 2
if current:
parts.append(current)
current = [(cx, cy)]
elif command == 2: # LineTo
for _ in range(count):
cx += zigzag(geom[i]); cy += zigzag(geom[i + 1]); i += 2
current.append((cx, cy))
elif command == 7: # ClosePath — consumes no parameters
if current:
current.append(current[0])
else:
raise ValueError(f"unknown command id {command} at index {i - 1}")
if current:
parts.append(current)
return parts
# A square ring: MoveTo(5,5), LineTo x3, ClosePath
print(decode_geometry([9, 10, 10, 26, 20, 0, 0, 20, 19, 0, 15]))
# [[(5, 5), (15, 5), (15, 15), (5, 15), (5, 5)]]
Three lines in that function are the whole lesson. cx and cy are declared once, outside the loop. ClosePath advances i by nothing. And a MoveTo inside a feature that already has points starts a new part rather than continuing the old one.
Interaction Effects
With the tile extent. All coordinates are in the local 0..extent grid, 4096 by default. Converting to geographic coordinates needs the tile’s (z, x, y), which the geometry does not carry — see tile coordinate systems for the transform.
With clipping and buffers. Coordinates outside 0..extent are legal and expected: they are the buffer strip carrying a neighbour’s geometry so joins do not break at the seam. A decoder that rejects out-of-range values will discard perfectly valid data.
With ring winding. For polygons, the sign of a ring’s signed area distinguishes an exterior ring from a hole — measured in tile space, where y grows downward, so the sign is opposite to the GeoJSON convention.
With simplification. Every vertex removed by geometry simplification removes two parameter integers, which is why simplification is the lever that moves the geometry band of the tile budget and nothing else does.
Performance Impact
Delta plus zigzag plus varint is what makes MVT compact. A vertex 3 units from its predecessor encodes as two single-byte varints; the same vertex as an absolute 12-bit coordinate pair costs four bytes before compression and compresses worse, because absolute coordinates share no common prefixes while small deltas repeat constantly.
Measured on a dense z14 road tile, absolute coordinates inflate the geometry band by roughly 2.4× uncompressed and 1.9× after gzip. That difference is the reason the encoding exists and the reason a custom exporter that “simplifies” by writing absolute values produces tiles that are suddenly over budget.
Common Mistakes
Resetting the cursor per command. Produces geometry where every part starts near the tile’s north-west corner. The symptom is a layer that looks like a starburst radiating from one point.
Consuming parameters after ClosePath. Desynchronises the stream. Points and lines decode fine and polygons turn to noise, which is a distinctive enough signature to diagnose from a screenshot.
Forgetting the arithmetic shift in zigzag. In languages where >> is logical rather than arithmetic, (value >> 1) ^ (-(value & 1)) decodes negatives incorrectly. Every second vertex lands on the wrong side of its predecessor.
Treating a repeated MoveTo as a continuation. A MoveTo with a count above 1 encodes several separate points — a multipoint — not the start of a line.
FAQ
Why are geometry integers unsigned when the deltas are signed?
Protocol Buffers encodes unsigned varints more compactly for small values, and a signed delta of -1 stored directly would be 0xFFFFFFFF — five bytes. Zigzag encoding maps small values of either sign onto small unsigned values, so -1 becomes 1 and costs a single byte. The signedness is recovered by the decoder, not carried in the wire format.
Can a feature’s geometry array be empty?
Yes, and it is legal. An empty array means the feature has no geometry in this tile — which happens when a feature is present for its attributes alone, or when a generator emits a placeholder. A decoder should return an empty geometry rather than raising.
Does ClosePath add a vertex?
Not in the encoded stream. It carries no parameters and does not move the cursor. Decoders typically append a copy of the ring’s first point when materialising GeoJSON, because GeoJSON requires a closed ring to repeat its first coordinate explicitly, but that vertex is synthesised on decode and is not in the tile.
Why do coordinates sometimes exceed 4096?
Because of the buffer: each tile carries a strip of its neighbours’ geometry so that strokes and outlines meet cleanly across the seam. Values in roughly -80..4176 are normal on a default Tippecanoe build. Rejecting them is a common decoder bug.
Related
- MVT Encoding Internals — the parent topic: the full protobuf schema this geometry array sits inside.
- Decoding MVT Tiles to GeoJSON in Python — the library route, and the affine transform to geographic coordinates.
- Debugging Clipped Geometry at Tile Boundaries — why out-of-range coordinates are correct.
- Tuning Simplification with the Detail Flag — how
--detailchanges the grid these integers address.