The Mapbox Vector Tile Spec & Tile Geometry Jump to heading

Every confusing thing about vector tiles traces back to one design decision: geometry is stored as small integers on a grid that belongs to the tile, not as coordinates on the Earth. Understanding that grid — how positions are encoded onto it, what happens at its edges, and what precision it actually offers — explains the blocky coastlines, the seams, the duplicated roads and the surprising file sizes all at once.

How a geographic coordinate becomes an integer pair inside one tile A four-stage transformation. A geographic longitude and latitude pair is first projected into Web Mercator metres. Those metres are converted to a fraction of the world at the target zoom, giving a position within the tile pyramid. The position is then made relative to the containing tile's own corner. Finally it is scaled by the tile extent and rounded to an integer on a grid running from zero to that extent, which is the value actually stored. Four transforms, and the last one loses precision on purpose lon, lat WGS 84 degrees what OSM stores nanodegree precision nothing tile-specific yet Web Mercator projected metres the projection tiles use poles are excluded distortion grows with latitude tile-relative fraction of a tile subtract the tile corner now in range zero to one zoom decides the tile size grid integer 0 to extent multiply and round this is what is stored precision follows zoom Only the final rounding is lossy, and how lossy it is depends entirely on how much ground the tile covers at that zoom.
The same source coordinate becomes a different integer in every tile that contains it, which is why tiles are independent.

The Problem This Topic Solves Jump to heading

You are writing or debugging something that produces or consumes vector tiles, and the behaviour does not match a mental model built on GeoJSON. Coordinates come back as small integers. A polygon that was valid in the source is reported as invalid by a tile reader. A feature you know exists appears twice. A line that should be continuous shows a hairline gap at a tile boundary.

The failure scenario worth naming is the silent one: a pipeline that encodes geometry without accounting for the grid produces tiles that render acceptably at the zoom the developer tested and fall apart elsewhere — self-intersections at low zoom where rounding collapsed two vertices onto the same grid point, invisible slivers at high zoom, and polygons whose winding order flipped so a client renders them as holes.

Prerequisites Jump to heading

Understand projection basics from Coordinate Reference Systems in OSM, because the tile grid sits on top of Web Mercator and inherits its distortions. Know the parent section’s tile pyramid model from OSM Vector Tiles & Rendering Pipelines. And a working knowledge of protocol buffers helps, since MVT is a protobuf schema — the same encoding dissected in PBF File Structure Deep Dive.

The Grid and Its Precision Jump to heading

A tile’s extent declares the size of its local coordinate grid; 4096 is conventional and nearly universal. Coordinates run from 0 to extent across the tile, with the origin at the top-left corner.

The ground distance represented by one grid unit is therefore the tile’s ground width divided by the extent. At the equator a zoom-\(z\) tile spans

wtile(z)=40075017 m2zw_{\text{tile}}(z) = \frac{40\,075\,017\ \text{m}}{2^{z}}

so one grid unit at extent 4096 covers

u(z)=400750172z4096 metres.u(z) = \frac{40\,075\,017}{2^{z} \cdot 4096}\ \text{metres}.

At zoom 14 that is about 0.6 metres; at zoom 8, about 38 metres; at zoom 4, about 600 metres. Away from the equator the figure shrinks by a factor of \(\cos(\varphi)\), so the same grid is finer at high latitudes.

Two practical rules fall out. Do not simplify below the grid: a tolerance finer than \(u(z)\) produces vertices that round onto the same grid point, costing time and, worse, sometimes creating zero-length segments that make a polygon invalid. And expect quantisation artefacts at low zoom: they are the format working as designed, not a bug in your simplifier.

Ground distance represented by one tile grid unit at each zoom, at the equator Five zoom levels with the ground size of a single grid unit at the conventional extent of four thousand and ninety six. At zoom four a unit spans roughly six hundred metres. At zoom eight it is about thirty eight metres. At zoom eleven it is about five metres. At zoom fourteen it is about sixty centimetres. At zoom sixteen it is about fifteen centimetres. A note observes that the figure shrinks with the cosine of latitude, so the same grid is finer away from the equator. One grid unit, by zoom, at the equator zoom 4 about 600 m zoom 8 about 38 m zoom 11 about 4.8 m zoom 14 about 0.6 m zoom 16 about 0.15 m Away from the equator every figure shrinks by the cosine of the latitude, so a Nordic tile resolves finer than a tropical one.
Simplifying below the figure for your target zoom costs processing time and buys nothing the grid can represent.

Command and Parameter Encoding Jump to heading

Geometry inside a feature is a flat array of unsigned integers holding interleaved commands and parameters.

A command integer packs an identifier in its low three bits and a repeat count in the remaining bits: command_integer = (id & 0x7) | (count << 3). Three commands exist — MoveTo (1), LineTo (2) and ClosePath (7). Each MoveTo or LineTo is followed by 2 × count parameter integers; ClosePath takes none.

Parameters are relative to the previous point and zigzag-encoded so small negative deltas stay small: parameter = (value << 1) ^ (value >> 31). Relative encoding is what keeps a detailed coastline compact, since consecutive vertices differ by a handful of grid units even when the absolute position is large.

Decoding one geometry array, command by command Four steps in decoding. Read a command integer and split it into a three-bit command identifier and a repeat count held in the upper bits. For a move or line command, read twice the count parameter integers. Undo the zigzag encoding on each parameter to recover a signed delta. Add each delta pair to a running cursor, which starts at the tile origin, to obtain absolute grid coordinates. A close-path command emits no parameters and returns the cursor to the ring's first point. Command, count, deltas, cursor read command low 3 bits is the id upper bits are the count read params two per repetition none for close path un-zigzag recover signed deltas small values stay small advance cursor add to the running point absolute grid position The cursor persists across commands within a feature, so a decoder that resets it between rings scatters the geometry.
Everything about the encoding is chosen so that a dense line costs a byte or two per vertex rather than eight.

Winding, Validity and Polygons Jump to heading

Polygon rings in MVT version 2 carry meaning in their winding order: an exterior ring is clockwise in the tile’s screen coordinate system, and an interior ring — a hole — is counter-clockwise. A polygon feature’s geometry is a sequence of rings where each exterior ring begins a new polygon and the counter-clockwise rings following it are its holes.

This is a frequent source of bugs for anyone coming from the OGC world, where the convention is the opposite way round and where hole membership is structural rather than inferred from winding. Encoding a ring with the wrong orientation produces a polygon a client renders as a hole in nothing, which typically shows up as a mysteriously missing landmass.

Validity is also affected by the grid. A ring that is valid in source coordinates can become invalid after quantisation, because two nearly-coincident vertices round to the same grid point and produce a zero-length segment or a spike. Encoders should therefore validate after quantisation, not before — the detection techniques in Detecting Self-Intersecting OSM Polygons with Shapely apply directly, just on the rounded coordinates.

Clipping and the Buffer Jump to heading

Geometry that extends beyond the tile must be clipped, and clipping exactly at the boundary is almost never what you want. A road drawn eight pixels wide needs geometry to continue past the edge, or the client has nothing to draw into the outer four pixels and a hairline seam appears.

The buffer is expressed in grid units and geometry is clipped to [-buffer, extent + buffer]. Coordinates outside 0…extent are legal in the format precisely for this reason. Larger buffers cost bytes in every tile; too-small buffers cost visible seams. The trade-off, and how to pick a value from your styled line widths, is in Choosing Tile Extent and Buffer Values, and the debugging workflow when it goes wrong is in Debugging Features Clipped at Tile Edges.

Validation and Error-Handling Matrix Jump to heading

Condition Root cause Detection Remediation
Geometry scattered across the tile Cursor reset between rings Rings start at the tile origin Keep one cursor for the whole feature
Landmass renders as a hole Exterior ring wound the wrong way Ring is counter-clockwise where clockwise expected Enforce winding after quantisation
Polygon invalid only in tiles Vertices collapsed by rounding Validity fails on grid coordinates, passes on source Simplify to at least one grid unit first
Hairline seams at tile edges Buffer smaller than half the styled width Gaps along boundaries at specific zooms Increase the buffer for the widest styled layer
Coordinates outside 0…extent Buffer geometry, not corruption Values slightly negative or above extent Expected; clamp only when rendering, never when encoding
Enormous tiles at high zoom Deltas large because points are unordered Parameter values far from zero Order vertices along the geometry before encoding
Attributes missing on some features Key or value index out of range Decoder error naming the index Build the key and value tables before encoding features

Performance and Scale Jump to heading

The encoding’s efficiency depends almost entirely on delta size, and delta size depends on vertex ordering. Geometry traversed in spatial order produces deltas of a few grid units, which zigzag and varint encoding store in one byte. The same vertices in arbitrary order produce deltas spanning the tile, costing two or three bytes each and defeating the design.

Attributes are the other half of the budget. Keys and values live in per-layer tables and features reference them by index, so a repeated string costs one entry plus a small index per feature rather than a full copy. That makes distinct value count the number to watch: a layer with ten distinct values across a million features is cheap, and one with a million distinct names is not.

Finally, tile size scales with feature count and vertex count, not with geographic area. A dense city tile at zoom 14 can be fifty times the size of a rural tile at the same zoom, which is why size budgets must be enforced per tile rather than assumed from the zoom.

Failure Modes and Gotchas Jump to heading

  • The cursor is per feature, not per ring. Resetting it between rings is the most common decoder bug and produces geometry scattered from the origin.
  • Winding is opposite to the OGC convention. Exterior rings are clockwise in screen coordinates here, and hole membership is inferred from orientation rather than declared.
  • Validate after quantisation. A polygon valid in source coordinates can be invalid on the grid.
  • Coordinates outside the extent are normal. They are buffer geometry; clamping them at encode time is what creates seams.
  • Extent is per layer. Nothing requires every layer in a tile to share one extent, and a decoder that assumes 4096 will misplace geometry in a layer that chose otherwise.
  • A feature identifier is not unique in the tile set. The same road carries the same identifier in every tile it crosses.
  • Zigzag is not two’s complement. Decoding parameters as signed integers directly produces enormous wrong values rather than an obvious error.

Integration Points Jump to heading

Upstream, the geometry entering an encoder should already be projected, simplified for the target zoom and valid — the generalization decisions in Cartographic Generalization of OSM Data happen before encoding, not during it. Downstream, the encoded tiles are packaged and served as described in Serving & Invalidating OSM Tiles.

When you are writing the encoder yourself rather than using a generator, Encoding OSM Geometry into MVT with Python puts the whole model into working code.

Guides in This Topic Jump to heading

Frequently Asked Questions Jump to heading

Why are vector tile coordinates integers rather than degrees?

Because integers on a small grid compress dramatically better and decode faster. A tile-local grid of a few thousand units means most coordinate deltas fit in a single byte after zigzag and variable-length encoding, where geographic degrees would need eight bytes each. The cost is that precision is fixed by the grid and therefore by the zoom level, which is exactly the trade a transport format should make.

What does a coordinate outside the tile extent mean?

It is buffer geometry, and it is entirely normal. Features are clipped to the tile plus a margin so that a client drawing a thick line has geometry to draw into the outer pixels rather than leaving a seam. Coordinates slightly below zero or slightly above the extent are the format working as intended; clamping them during encoding is what produces the hairline gaps people then try to fix by increasing the buffer.

Why did my polygon become invalid only after encoding?

Because quantisation to the tile grid can collapse two nearly-coincident vertices onto the same point, producing a zero-length segment or a spike that makes the ring invalid. The source geometry was fine at full precision. The fix is to simplify with a tolerance of at least one grid unit before encoding, and to run validity checks on the quantised coordinates rather than on the originals.

Which way should polygon rings wind?

Exterior rings clockwise and interior rings counter-clockwise, in the tile’s screen coordinate system where the origin is top-left and y increases downwards. This is the opposite of the convention most geospatial libraries use, and hole membership is inferred from the orientation rather than declared structurally. An exterior ring wound the wrong way is typically rendered as a hole, which looks like a missing feature rather than an orientation bug.

Can different layers in one tile use different extents?

Yes. The extent is declared per layer, so nothing prevents a detailed layer from using a finer grid than a coarse one. In practice almost everything uses the conventional value, which is why decoders that hard-code it usually work — right up until they encounter a tile that does not, and silently misplace every coordinate in that layer by the ratio between the assumed and actual extents.

Up one level: OSM Vector Tiles & Rendering Pipelines.