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.

Geographic coordinates quantised to a 4096-unit tile grid, clipped to the tile, delta-encoded and packed as protocol buffers.
Four transformations, each contributing to the size difference against GeoJSON.

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.

One tile of 182 buildings as 5.8 kB of MVT against 95.5 kB of GeoJSON, and 4.5 kB against 14.7 kB gzipped.
The gzipped comparison is the fair one, and MVT still wins by 2.6 times.

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.

A building crossing a tile boundary stored clipped into both tiles, producing two hits for one click.
Clipping is why a tile renders independently, and why a cross-tile query needs deduplication.

Edge cases or notes

  • extent=4096 is 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 .pbf gzipped; every client expects it.
  • Never use vector tiles as the analysis source. Geometry is clipped and quantised.

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.