Vector Tiles Explained: MVT, Layers and Why They Are Not GeoJSON
Problem statement
A vector tile carries geometry and attributes rather than a rendered picture, so styling happens in the browser and one set of tiles serves every style.
It is also not GeoJSON in a box. The geometry is re-encoded into tile-local integer coordinates, clipped to the tile, and compressed as protocol buffers β which is why it is so much smaller.
Measured on 182 real building polygons inside one zoom-15 tile:
MVT, no attributes 5.80 kB gzipped 4.54 kB
MVT, two attributes 8.02 kB gzipped 5.73 kB
the same as GeoJSON 95.47 kB gzipped 14.72 kB
Sixteen times smaller raw, and 2.6 times smaller after gzip. The gzipped comparison is the honest one, since every server compresses β and MVT still wins comfortably.
Quick answer
import mapbox_vector_tile as mvt
from shapely.ops import transform
def to_tile_coords(geom, bounds, extent=4096):
"""Geographic coordinates to tile-local integers."""
west, south, east, north = bounds
def convert(x, y, z=None):
return ((np.asarray(x) - west) / (east - west) * extent,
(np.asarray(y) - south) / (north - south) * extent)
return transform(convert, geom)
features = [{"geometry": to_tile_coords(row.geometry, tile_bounds),
"properties": {"building": str(row.building)}}
for _, row in inside.iterrows()]
encoded = mvt.encode([{"name": "buildings", "features": features}])
The extent is the tile's internal coordinate grid β 4,096 by convention. Geometry is quantised to that grid, which is a deliberate precision loss and most of the size saving.
Step-by-step solution
1. Understand the coordinate space
Inside a tile, coordinates are integers from 0 to extent, usually 4,096. That is the tile's own grid, independent of any CRS.
At zoom 15 a tile covers about 728 m at 53Β° north, so 4,096 units give a resolution of about 18 cm. At zoom 10 the same 4,096 units span 23 km, giving 5.7 m.
The quantisation is therefore zoom-dependent and always finer than the display can show β one screen pixel is 16 tile units at extent=4096 with 256-pixel tiles.
2. Organise features into layers
mvt.encode([
{"name": "buildings", "features": building_features},
{"name": "roads", "features": road_features},
])
A tile holds several named layers, and the style sheet refers to them by name. Layers are the unit of styling and of filtering: a client can render one layer and ignore another without decoding it fully.
Keep layer names stable β they are part of the contract with every style that consumes the tiles.
3. Keep attributes minimal
MVT, no attributes 5.80 kB
MVT, two attributes 8.02 kB
Two string attributes on 182 features added 38%. Attributes are stored as a key table plus per-feature indices, so repeated values are cheap and unique ones β names, identifiers, free text β are not.
Include only what the style needs, plus the minimum for interaction. A feature identifier for click-through is worth its bytes; a full address is usually not.
4. Expect geometry to be clipped at tile edges
A building straddling a boundary appears in both tiles, cut. That is by design: each tile is independently renderable.
Two consequences. A feature can appear more than once in a query result, so deduplicate by identifier. And measuring geometry from vector tiles gives clipped values β a tile is a rendering format, not an analysis format.
Encoders usually clip slightly beyond the tile edge β a buffer of 8 to 64 units β so that lines and polygon outlines are not visibly cut at the seam.
5. Generalise per zoom, and drop rather than simplify
Simplification alone does very little for small features. Measured on 59,391 building footprints at 91 m per pixel (zoom 10), simplifying to one-pixel tolerance kept 57.9% of vertices β because a footprint is already near the minimum vertex count for a polygon.
Dropping features below two screen pixels kept 3 of 59,391 buildings.
At low zoom the right generalisation is to remove features, not to smooth them. See Generalisation for zoom levels explained.
Code examples
Example 1 β encoding a tile from a GeoDataFrame
import numpy as np
import geopandas as gpd
import mapbox_vector_tile as mvt
import mercantile
from shapely.ops import transform
def encode_tile(gdf, tile, layer_name="layer", extent=4096, buffer=64,
properties=(), id_column=None):
"""One MVT for one XYZ tile, from a GeoDataFrame in EPSG:4326."""
bounds = mercantile.bounds(tile)
pad_x = (bounds.east - bounds.west) * buffer / extent
pad_y = (bounds.north - bounds.south) * buffer / extent
inside = gdf.cx[bounds.west - pad_x:bounds.east + pad_x,
bounds.south - pad_y:bounds.north + pad_y]
if inside.empty:
return None
def to_tile(x, y, z=None):
return ((np.asarray(x) - bounds.west) / (bounds.east - bounds.west) * extent,
(np.asarray(y) - bounds.south) / (bounds.north - bounds.south) * extent)
features = []
for idx, row in inside.iterrows():
if row.geometry is None or row.geometry.is_empty:
continue
feature = {"geometry": transform(to_tile, row.geometry),
"properties": {k: ("" if row.get(k) is None else str(row[k]))
for k in properties}}
if id_column:
feature["id"] = int(row[id_column])
features.append(feature)
encoded = mvt.encode([{"name": layer_name, "features": features}],
extents=extent)
print(f" z{tile.z}/{tile.x}/{tile.y}: {len(features)} features, "
f"{len(encoded) / 1024:.2f} kB")
return encoded
The buffer is what stops polygon outlines being visibly cut at tile seams. Sixty-four units at extent=4096 is 1.6% of the tile, which is enough for typical line widths.
Example 2 β measuring what attributes cost
import gzip
import mapbox_vector_tile as mvt
def attribute_cost(features_geometry_only, attribute_sets, layer="layer"):
"""How much does each attribute add, raw and gzipped?"""
base = mvt.encode([{"name": layer, "features": features_geometry_only}])
base_gz = len(gzip.compress(base, 6))
print(f" geometry only {len(base) / 1024:7.2f} kB "
f"gz {base_gz / 1024:6.2f} kB")
for name, features in attribute_sets.items():
encoded = mvt.encode([{"name": layer, "features": features}])
gz = len(gzip.compress(encoded, 6))
print(f" {name:22} {len(encoded) / 1024:7.2f} kB "
f"gz {gz / 1024:6.2f} kB "
f"({len(encoded) / len(base):.2f}x raw, {gz / base_gz:.2f}x gz)")
Run this once on a representative dense tile. Attributes that add 5% are free; ones that double the tile are a design decision, and the alternative is fetching detail on demand by feature id.
Example 3 β building a whole pyramid
import os
import gzip
import mercantile
def build_pyramid(gdf, bounds, out_dir, zooms=range(10, 16),
layer_name="layer", properties=(), min_area_px=4.0,
compress=True):
"""Tiles for a zoom range, with per-zoom generalisation."""
utm = gdf.to_crs(gdf.estimate_utm_crs())
areas = utm.geometry.area
written, total_bytes = 0, 0
for zoom in zooms:
lat = (bounds[1] + bounds[3]) / 2
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
keep = areas >= min_area_px * m_per_px ** 2
subset = gdf[keep.values]
print(f" z{zoom}: {len(subset):,} of {len(gdf):,} features "
f"({keep.mean():.1%}) at {m_per_px:.1f} m/px")
for tile in mercantile.tiles(*bounds, zooms=[zoom]):
encoded = encode_tile(subset, tile, layer_name=layer_name,
properties=properties)
if not encoded:
continue
directory = os.path.join(out_dir, str(tile.z), str(tile.x))
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, f"{tile.y}.pbf")
payload = gzip.compress(encoded, 6) if compress else encoded
with open(path, "wb") as f:
f.write(payload)
written += 1
total_bytes += len(payload)
print(f" {written:,} tiles, {total_bytes / 1e6:.1f} MB")
return written
Filtering by area per zoom is the generalisation that actually works. Serving all 59,391 buildings at zoom 10 produces tiles nobody can render and nobody can see; serving the 3 that are visible produces a map.
Explanation
Why MVT is so much smaller than GeoJSON
Four mechanisms, all absent from GeoJSON.
Integer coordinates. GeoJSON writes -2.2453891 β ten bytes of text per ordinate. MVT writes a small integer on a 4,096 grid.
Delta encoding. Consecutive vertices are stored as differences, which are small numbers and compress well.
Varint packing. Small integers take one byte; larger ones take more. A delta of 3 is one byte.
A shared key table. Attribute names appear once per layer, not once per feature.
Measured, the raw difference was 16Γ and the gzipped difference 2.6Γ. Gzip recovers much of GeoJSON's redundancy, which is why the fair comparison is the compressed one β and MVT still wins because it removes redundancy gzip cannot.
Why the tile grid is not a CRS
Tile-local coordinates are integers relative to one tile's corner. They mean nothing outside that tile, and there is no CRS in an MVT file at all.
The convention is that tiles are on the Web Mercator XYZ grid, so the client reconstructs geographic coordinates from (z, x, y) and the extent. Change the grid and the same bytes describe different ground.
This is why an MVT archive must record its tiling scheme and why mixing TMS-numbered and XYZ-numbered tiles produces a vertically mirrored map.
Why features are clipped and duplicated
Each tile must render independently, without its neighbours. A feature crossing a boundary is therefore cut and appears in both tiles.
For rendering that is exactly right. For anything else it is a trap: counting features across tiles double-counts, and measuring their area gives the clipped area.
Vector tiles are a rendering format. Analysis belongs on the source data.
Why quantisation is not a problem
At extent=4096 and 256-pixel tiles, one screen pixel is 16 tile units. Quantisation error is therefore at most half a tile unit β about a thirtieth of a pixel.
That is invisible, and it is why the precision loss buys size for free at display scales. It does mean vector tiles are unsuitable as a data archive: the geometry has been irreversibly snapped to a zoom-dependent grid.
Edge cases or notes
extent=4096is the convention, giving 16 units per screen pixel.- Tiles have no CRS. The grid is implied by the tiling scheme.
- Features are clipped and duplicated across tile boundaries.
- Deduplicate by feature id when querying across tiles.
- Use a buffer of 8β64 units so outlines are not cut at seams.
- Attributes cost real bytes β two strings added 38% here.
- Serve
.pbfgzipped; every client expects it. - Never use vector tiles as the analysis source. Geometry is clipped and quantised.
Internal links
- Web map tiles explained: XYZ, zoom levels and the tile pyramid β the addressing scheme
- How to build vector tiles from a GeoDataFrame in Python β the full pipeline
- PMTiles and MBTiles explained β packaging the tiles
- Generalisation for zoom levels explained β why dropping beats simplifying
- My GeoJSON is too big for the browser β the problem vector tiles solve
- How to style data-driven colours in a web map β using the attributes you kept
- WKT, WKB and GeoJSON: how geometry is actually stored β the encodings compared
- My tiles are offset or in the wrong place β grid and scheme mismatches
FAQ
What is a vector tile?
A packet of geometry and attributes for one map tile, encoded as protocol buffers with integer coordinates on a tile-local grid β styled by the client rather than pre-rendered.
How much smaller are vector tiles than GeoJSON?
Measured on 182 buildings in one tile: 5.80 kB against 95.47 kB raw, and 4.54 kB against 14.72 kB gzipped.
What is the extent?
The tile's internal coordinate grid, conventionally 4,096 units. With 256-pixel tiles that is 16 units per screen pixel, so quantisation is invisible.
Why does one feature appear in two tiles?
Because each tile is independently renderable, so features crossing a boundary are clipped into both. Deduplicate by feature id when querying.
Can I do analysis on vector tiles?
No. Geometry is clipped at tile edges and quantised to a zoom-dependent grid. Analyse the source data.
How many attributes should I include?
As few as the style needs, plus an identifier. Two string attributes added 38% to a tile here.
Do I need to gzip the tiles?
Yes. Serve .pbf with Content-Encoding: gzip; every client expects it and it roughly halves the transfer.