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

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

One feature's geometry array, field by fieldA geometry array shown as a command integer, its parameter pairs, a second command integer, its parameters, and a closing command with no parameters.THE STREAM9MoveTo x1(1 << 3) | 150, 342 paramszigzag deltas from 0,018LineTo x2(2 << 3) | 24 pairs8 paramseach delta from theprevious point15ClosePathno parameters at all
There is no separator and no length prefix. The count inside each command is the only thing telling a decoder where the next command begins.

Production Command: A Complete Decoder

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

The cursor is the decoder's only mutable state, and it never resetsThe cursor starts at the origin, moves on every MoveTo and LineTo, and is left unchanged by ClosePath — persisting across commands and across sub-geometries within one feature.CURSOR STATEOrigin0, 0 at feature startCursor movedafter MoveToCursor extendedafter each LineToMoveTo applies the firstdeltaLineTo accumulates, neverrestartsa new MoveTo starts a part,keeping the cursor
Resetting the cursor between commands is the classic decoder bug. It produces geometry radiating from the tile's north-west corner, which is a distinctive enough signature to diagnose from a screenshot.

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.

What each encoding choice costsFour encodings of the same geometry measured: delta with zigzag varints, delta without zigzag, absolute coordinates, and absolute coordinates as fixed 32-bit integers.ENCODING COSTKB of geometry in one z14 road tileDelta + zigzag varint (MVT)118 KBDelta, no zigzag176 KBAbsolute varint214 KBAbsolute fixed 32-bit283 KB
Zigzag alone is worth about a third. It costs one shift and one XOR per value, which is why every implementation does it.

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.