Vector Tiles Explained: MVT, Layers and Why They Are Not GeoJSON
On this page
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 the OpenStreetMap buildings inside one zoom-15 tile in the centre of Leiden (Example 2):
MVT, no attributes 69.61 kB gzipped 45.83 kB
MVT, two attributes 86.07 kB gzipped 48.12 kB
the same as GeoJSON 928.48 kB gzipped 126.63 kB
Thirteen times smaller raw, and 2.8 times smaller after gzip. The gzipped comparison is the honest one, since every server compresses — and MVT still wins comfortably.
Quick answer
import geopandas as gpd
import mapbox_vector_tile as mvt
import mercantile
import numpy as np
from shapely.geometry import box
from shapely.ops import transform
def to_tile_coords(geom, bounds, extent=4096):
"""Web Mercator metres to tile-local integers (y up; the encoder flips it)."""
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)
buildings = gpd.read_parquet("buildings.parquet")
tile = mercantile.tile(4.4938, 52.1585, 15) # central Leiden
tile_bounds = tuple(mercantile.xy_bounds(tile)) # the tile in EPSG:3857 metres
inside = buildings.to_crs(3857).clip(box(*tile_bounds))
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}])
print(f"tile {tile.z}/{tile.x}/{tile.y}: {len(features):,} features, {len(encoded) / 1e3:.2f} kB")
tile 15/16793/10800: 2,699 features, 82.27 kB
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. The mapping is from Web Mercator, not from longitude and latitude: the tile grid is Web Mercator, and a linear mapping from degrees bends every tile's rows slightly.
Tile coordinates, layers, minimal attributes
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.
import math
for zoom in (15, 10):
ground = 40_075_016.686 * math.cos(math.radians(52.16)) / 2 ** zoom
print(f"zoom {zoom}: a tile is {ground:,.0f} m across at Leiden, one tile unit {ground / 4096 * 100:,.1f} cm")
zoom 15: a tile is 750 m across at Leiden, one tile unit 18.3 cm
zoom 10: a tile is 24,008 m across at Leiden, one tile unit 586.1 cm
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
geometry only 69.61 kB
building 80.34 kB
building + name 86.07 kB
Two string attributes on the tile's buildings added 24% raw and 5% gzipped (Example 2). 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 Leiden's 58,879 building footprints at 93.8 m per pixel (zoom 10), simplifying to one-pixel tolerance kept 54.5% of vertices — because a footprint is already near the minimum vertex count for a polygon.
Dropping features below two screen pixels kept one of 58,879 buildings.
At low zoom the right generalisation is to remove features, not to smooth them. See Generalisation for zoom levels explained.
Encoding Leiden's buildings as tiles
The data used on this page
The layer is every building in Leiden in OpenStreetMap — about 59,000 footprints with their building type and, where mapped, a name, height and number of levels — fetched with OSMnx through an Overpass mirror and saved as GeoParquet in EPSG:4326. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import requests
import zipfile
import geopandas as gpd
import osmnx as ox
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-vector-tiles-explained-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
if not os.path.exists("buildings.parquet"):
found = ox.features_from_place("Leiden, Netherlands", {"building": True}).reset_index()
found = found[found.geom_type.isin(["Polygon", "MultiPolygon"])]
keep = [c for c in ("osmid", "building", "name", "height", "building:levels") if c in found.columns]
found[keep + ["geometry"]].rename(columns={"building:levels": "levels"}).to_parquet("buildings.parquet")
buildings = gpd.read_parquet("buildings.parquet")
print(f"{len(buildings):,} OSM buildings in Leiden, EPSG:{buildings.crs.to_epsg()}; "
f"columns {[c for c in buildings.columns if c != 'geometry']}")
58,879 OSM buildings in Leiden, EPSG:4326; columns ['building', 'name', 'height', 'levels']
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, verbose=True):
"""One MVT for one XYZ tile, from a GeoDataFrame in EPSG:3857."""
bounds = mercantile.xy_bounds(tile)
pad = (bounds.right - bounds.left) * buffer / extent
inside = gdf.cx[bounds.left - pad:bounds.right + pad,
bounds.bottom - pad:bounds.top + pad]
if inside.empty:
return None
inside = inside.clip(box(bounds.left - pad, bounds.bottom - pad,
bounds.right + pad, bounds.top + pad))
def to_tile(x, y, z=None):
return ((np.asarray(x) - bounds.left) / (bounds.right - bounds.left) * extent,
(np.asarray(y) - bounds.bottom) / (bounds.top - bounds.bottom) * 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}],
default_options={"extents": extent})
if verbose:
print(f" z{tile.z}/{tile.x}/{tile.y}: {len(features)} features, "
f"{len(encoded) / 1024:.2f} kB")
return encoded
merc = buildings.to_crs(3857)
merc["osm_id"] = range(len(merc))
tile_bytes = encode_tile(merc, tile, layer_name="buildings", properties=("building",), id_column="osm_id")
print(mvt.decode(tile_bytes)["buildings"]["features"][0]["properties"])
z15/16793/10800: 2816 features, 94.72 kB
{'building': 'yes'}
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)")
def features_for(frame, props):
return [{"geometry": to_tile_coords(g, tile_bounds),
"properties": {k: str(v) for k, v in row.items() if k in props and v is not None}}
for g, (_, row) in zip(frame.geometry, frame.iterrows())]
attribute_cost(features_for(inside, ()),
{"building": features_for(inside, ("building",)),
"building + name": features_for(inside, ("building", "name"))},
layer="buildings")
geojson = buildings.clip(box(*mercantile.bounds(tile)))[["building", "name", "geometry"]].to_json().encode()
print(f" the same as GeoJSON {len(geojson) / 1024:7.2f} kB gz {len(gzip.compress(geojson, 6)) / 1024:6.2f} kB")
geometry only 69.61 kB gz 45.83 kB
building 80.34 kB gz 47.57 kB (1.15x raw, 1.04x gz)
building + name 86.07 kB gz 48.12 kB (1.24x raw, 1.05x gz)
the same as GeoJSON 928.48 kB gz 126.63 kB
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 gzip
import math
import os
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, verbose=False)
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
build_pyramid(merc, tuple(buildings.total_bounds), "tiles", zooms=range(10, 16),
layer_name="buildings", properties=("building",))
z10: 1 of 58,879 features (0.0%) at 93.8 m/px
z11: 11 of 58,879 features (0.0%) at 46.9 m/px
z12: 211 of 58,879 features (0.4%) at 23.4 m/px
z13: 947 of 58,879 features (1.6%) at 11.7 m/px
z14: 3,603 of 58,879 features (6.1%) at 5.9 m/px
z15: 33,438 of 58,879 features (56.8%) at 2.9 m/px
90 tiles, 0.9 MB
Filtering by area per zoom is the generalisation that actually works. Serving all 58,879 buildings at zoom 10 produces tiles nobody can render and nobody can see; serving the one that is visible produces a map.
Why MVT is so much smaller than GeoJSON
Why MVT is so much smaller than GeoJSON
Four mechanisms, all absent from GeoJSON.
Integer coordinates. GeoJSON writes 4.4738351 — nine 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 13.3× and the gzipped difference 2.8×. 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.
Extents, missing CRS and clipped duplicates
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 24% raw 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 one dense tile of central Leiden: 69.61 kB against 928.48 kB raw, and 45.83 kB against 126.63 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 24% to a tile here, raw.
Do I need to gzip the tiles?
Yes. Serve .pbf with Content-Encoding: gzip; every client expects it. On the measured tile it cut the transfer by a third, from 69.61 kB to 45.83 kB.