Vector Tile Generation Pipelines with Tippecanoe and GDAL
Serving interactive maps at scale means shipping geometry, not pixels — and doing it in a way that stays small enough for a browser to fetch on demand at every zoom level. Mapbox Vector Tiles (MVT) solve this by slicing your data into a pyramid of small, self-contained protobuf tiles, each carrying only the features and detail appropriate to its zoom. The hard part is automation: a naive export dumps every vertex into every tile, producing multi-megabyte low-zoom tiles that stall the client and blow past CDN limits. This guide builds a reproducible MVT pipeline where zoom-dependent generalization, feature dropping, attribute filtering, and packaging are all driven from code, using Tippecanoe for the heavy tiling and GDAL for the surrounding data plumbing.
Prerequisites and Environment Configuration
Vector tile pipelines are sensitive to tool versions because the MVT spec, the tile extent defaults, and Tippecanoe’s dropping heuristics have all changed across releases. Pin everything.
- Tippecanoe 2.x — the maintained felt/tippecanoe fork. Confirm with
tippecanoe --version; 2.x adds--drop-densest-as-neededimprovements andtile-joinoverzoom support that this pipeline relies on. Install via Homebrew, apt, or from source with a C++17 toolchain. - GDAL / OGR 3.8+ with the MVT driver compiled in — verify with
ogrinfo --formats | grep MVT. The MVT driver reads and writes both directory-of-tiles and MBTiles containers, and provides an alternative tiling path when you need per-layer control that Tippecanoe does not expose. - Python 3.10+ with:
geopandas>=0.14andpyproj>=3.6for reprojection and geometry inspectionmapbox-vector-tile>=2.0for pure-Python encode/decode of individual tiles when you need to inspect or synthesize MVT payloadsmbutil(CLI) for unpacking MBTiles back to az/x/ydirectory during verification
- CRS discipline: the standard web tiling scheme is built on Web Mercator, EPSG:3857. Tippecanoe expects EPSG:4326 input and projects internally; GDAL’s MVT writer expects EPSG:3857. Mixing these silently misplaces features. Reproject deliberately for whichever path you take.
- Container format: the pipeline outputs MBTiles (an SQLite database of gzipped tiles). For serverless delivery you can additionally convert to PMTiles, a single-file format served over HTTP range requests — covered under performance patterns.
Conceptual Foundation: The Tile Pyramid and Zoom-Dependent Detail
Web maps are organized as a tile pyramid. At zoom z the world is divided into a 2^z × 2^z grid of square tiles, each addressed by integer (z, x, y) where x counts columns east from the antimeridian and y counts rows south from the top of the Web Mercator plane. Zoom 0 is a single tile covering the whole world; every zoom increment splits each tile into four, so tile count grows as 4^z. This quadrupling is the central budgeting fact of the whole pipeline: detail you add at low zoom is cheap per feature but expensive per tile because one tile serves an enormous area, while detail at high zoom is spread across millions of tiny tiles.
Converting a longitude/latitude pair to a tile index uses the standard Web Mercator slippy-map formula:
import math
def lonlat_to_tile(lon: float, lat: float, z: int) -> tuple[int, int]:
"""Return the (x, y) tile index containing a coordinate at zoom z."""
lat_rad = math.radians(lat)
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return x, y
Inside each tile, MVT does not store real-world coordinates. Geometry is quantized to an integer grid called the tile extent, 4096 units on a side by default, with (0, 0) at the top-left corner. A feature’s position is expressed as integer offsets on that 0–4096 grid, which is what makes tiles compact and resolution-independent — the client rescales the grid to whatever pixel size it renders. The consequence is that precision is bounded by the extent: at low zoom, where one tile spans thousands of kilometres, a single grid step is hundreds of metres, so fine geometry inevitably snaps to the grid. This is the origin of the coordinate quantization artifacts discussed later, and the reason low zoom needs generalized geometry rather than full-resolution vertices.
Zoom-dependent generalization is therefore not an optimization bolted on at the end — it is intrinsic to the pyramid. The same road network should appear as a handful of simplified motorways at zoom 5 and the full street grid at zoom 15. Achieving that means dropping features that carry no visual information at coarse zooms and simplifying the geometry of those that remain, a discipline shared with the broader theory of generalization and simplification. The diagram below shows how a single dataset flows through the pyramid, shedding features and vertices as zoom decreases.
Step-by-Step Implementation
Step 1: Reproject to the Tiling CRS
Standardize the input CRS before anything else. For the Tippecanoe path, reproject to EPSG:4326 because Tippecanoe interprets input coordinates as geographic degrees; for the GDAL MVT writer, reproject to EPSG:3857 because that driver tiles in projected metres. Getting this wrong is the single most common cause of an empty or misplaced tileset.
import geopandas as gpd
def reproject_for_tiling(input_gpkg: str, target_epsg: int = 4326) -> gpd.GeoDataFrame:
"""Load a GeoPackage and reproject to the tiling CRS.
target_epsg=4326 for the Tippecanoe path (it maps degrees to the pyramid).
target_epsg=3857 for GDAL's ogr2ogr MVT writer (projected metres).
"""
gdf = gpd.read_file(input_gpkg)
if gdf.crs is None:
raise ValueError(f"{input_gpkg} has no CRS; cannot tile safely")
if gdf.crs.to_epsg() != target_epsg:
gdf = gdf.to_crs(epsg=target_epsg)
return gdf
Step 2: Choose Min and Max Zoom
The zoom range is a cartographic decision, not a technical default. Set maxzoom to the level where your finest features are individually distinguishable, and minzoom to the coarsest level at which the layer still communicates something. Because tile count scales as 4^z, extending maxzoom by two levels multiplies build time and storage roughly sixteenfold, so choose the smallest range that serves the map. This choice is tightly coupled to how web zoom levels map onto real-world scale, which is worked through in scale mapping for web and print.
# Typical ranges by dataset character
ZOOM_RANGES = {
"country_boundaries": (0, 8), # coarse; overzoom above 8 on the client
"regional_roads": (5, 13),
"urban_parcels": (12, 16), # dense detail only needed when zoomed in
}
Step 3: Run Tippecanoe with Generalization and Dropping
Tippecanoe’s defaults are conservative, so pass explicit flags. --drop-densest-as-needed thins the densest features per tile until the tile fits the size budget — this is what keeps low-zoom tiles small without you hand-tuning a drop rate. --simplification controls Douglas–Peucker aggressiveness on retained geometry, and --coalesce-densest-as-needed merges rather than discards where features share attributes. The layer name is fixed with -l so the downstream style engine can target it deterministically.
TIPPECANOE_FLAGS = [
"--drop-densest-as-needed", # thin densest features to meet size budget
"--simplification=10", # Douglas-Peucker tolerance on retained lines
"--coalesce-densest-as-needed",
"--no-tile-size-limit", # we enforce our own budget in verification
"--force", # overwrite an existing output
]
Step 4: Package and Verify the MBTiles
Tippecanoe writes an MBTiles SQLite archive directly with -o. After the build, verify structurally rather than trusting exit code alone: read the metadata table for the declared zoom range and layer name, and sample the tiles table for oversized rows. tile-join can restitch or filter a tileset, and mbutil can explode it to a z/x/y directory for spot inspection.
import sqlite3
def summarize_mbtiles(path: str) -> dict:
"""Read metadata and the largest tile from an MBTiles archive."""
with sqlite3.connect(path) as con:
meta = dict(con.execute("SELECT name, value FROM metadata").fetchall())
biggest = con.execute(
"SELECT zoom_level, LENGTH(tile_data) AS n "
"FROM tiles ORDER BY n DESC LIMIT 1"
).fetchone()
return {"metadata": meta, "largest_tile_bytes": biggest[1], "at_zoom": biggest[0]}
Complete Working Code Example
The following script is the full automated pipeline: reproject, tile with Tippecanoe via subprocess, and validate the MBTiles output against a per-tile byte budget. It fails loudly on a missing CRS, a non-zero Tippecanoe exit, or an oversized tile, which makes it safe to drop into a CI job or a batch queue worker.
"""
build_vector_tiles.py
Automated MVT pipeline:
reproject GeoPackage -> tippecanoe -> MBTiles -> validate byte budget.
"""
import json
import os
import shutil
import sqlite3
import subprocess
import tempfile
from pathlib import Path
import geopandas as gpd
MAX_TILE_BYTES = 500 * 1024 # 500 KB per-tile budget
def build_vector_tiles(
input_gpkg: str,
out_mbtiles: str,
minzoom: int,
maxzoom: int,
layer_name: str = "features",
max_tile_bytes: int = MAX_TILE_BYTES,
) -> dict:
"""Reproject, tile with tippecanoe, and validate the resulting MBTiles.
Returns a summary dict. Raises on missing CRS, tippecanoe failure,
or any tile exceeding the byte budget.
"""
if shutil.which("tippecanoe") is None:
raise RuntimeError("tippecanoe not found on PATH")
# 1. Reproject to EPSG:4326 for tippecanoe and stage as GeoJSON.
gdf = gpd.read_file(input_gpkg)
if gdf.crs is None:
raise ValueError(f"{input_gpkg} has no CRS; refusing to tile")
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
Path(out_mbtiles).parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
staged = os.path.join(tmp, "staged.geojson")
gdf.to_file(staged, driver="GeoJSON")
# 2. Tile. --drop-densest-as-needed handles low-zoom size; per-feature
# simplification keeps retained geometry light without vertex spam.
cmd = [
"tippecanoe",
"-o", out_mbtiles,
"-l", layer_name,
f"--minimum-zoom={minzoom}",
f"--maximum-zoom={maxzoom}",
"--drop-densest-as-needed",
"--coalesce-densest-as-needed",
"--simplification=10",
"--force",
staged,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"tippecanoe failed:\n{proc.stderr}")
# 3. Validate the archive: metadata sanity + byte-budget enforcement.
with sqlite3.connect(out_mbtiles) as con:
meta = dict(con.execute("SELECT name, value FROM metadata").fetchall())
offenders = con.execute(
"SELECT zoom_level, tile_column, tile_row, LENGTH(tile_data) AS n "
"FROM tiles WHERE n > ? ORDER BY n DESC",
(max_tile_bytes,),
).fetchall()
n_tiles = con.execute("SELECT COUNT(*) FROM tiles").fetchone()[0]
if offenders:
z, x, y, n = offenders[0]
raise ValueError(
f"{len(offenders)} tiles over budget; worst {n} bytes at z{z}/{x}/{y}. "
f"Lower --maximum-zoom or raise dropping aggressiveness."
)
bounds = meta.get("bounds", "unknown")
return {
"mbtiles": out_mbtiles,
"layer": layer_name,
"zoom": f"{minzoom}-{maxzoom}",
"tile_count": n_tiles,
"bounds": bounds,
}
if __name__ == "__main__":
summary = build_vector_tiles(
input_gpkg="data/regional_roads.gpkg",
out_mbtiles="tiles/regional_roads.mbtiles",
minzoom=5,
maxzoom=13,
layer_name="roads",
)
print(json.dumps(summary, indent=2))
For cases where you need to construct or inspect an individual tile rather than an entire pyramid — synthesizing a test tile, or debugging what actually landed in one — the pure-Python mapbox_vector_tile library encodes geometry directly onto the tile extent grid:
import mapbox_vector_tile
# Coordinates are already in the 0..4096 tile-extent space, not lon/lat.
tile = mapbox_vector_tile.encode({
"name": "roads",
"features": [
{
"geometry": "LINESTRING(0 0, 2048 2048, 4096 2048)",
"properties": {"kind": "primary", "ref": "A1"},
}
],
}, default_options={"extent": 4096})
decoded = mapbox_vector_tile.decode(tile)
print(decoded["roads"]["features"][0]["properties"]) # {'kind': 'primary', 'ref': 'A1'}
Because the client rescales the extent to its render size, the same encoded tile is used at every physical zoom of that (z, x, y) — which is exactly why attributes like kind and ref should stay small and stable so a downstream rule-based styling engine can drive appearance without re-tiling.
Performance Optimization Patterns
1. Parallel tiling (near-linear in cores). Tippecanoe parallelizes internally, but for very large inputs split the source by geography, tile each shard, and merge with tile-join. Sharding turns an O(N) single-threaded pass into work that scales with core count; on a multi-region dataset this is the difference between a minutes-long and an hours-long build. Keep shards aligned to low-zoom tile boundaries so tile-join does not have to reconcile overlapping features.
2. Enforce a per-tile byte budget (<500 KB). Renderers stall when a tile balloons, and CDNs may reject oversized objects. The validation step above rejects any tile over 500 KB. When the budget is exceeded, the levers are: lower maxzoom, raise dropping aggressiveness (--drop-rate or --drop-densest-as-needed), or split a dense layer into its own tileset served on a separate source.
3. Attribute pruning. Every property is copied into every tile at every zoom, so unused columns multiply archive size linearly. Drop them before tiling with -y (include-only) or -x (exclude) flags, or prune in GeoPandas with gdf[["geometry", "kind", "ref"]]. A tileset that carries only the three attributes the style actually reads can be a fraction of one that ships the full source schema.
4. PMTiles for serverless delivery. Convert the MBTiles to a single PMTiles archive with pmtiles convert, then host it on object storage (S3, R2, GCS). Clients fetch individual tiles via HTTP range requests with no tile server process at all, which collapses operational cost and pairs naturally with edge caching. The invalidation semantics of that cache layer are their own topic, covered in tile cache and invalidation.
Common Pitfalls and Debugging
1. Oversized tiles at low zoom.
Symptom: z3 tiles are 2–4 MB while z14 tiles are 8 KB. One low-zoom tile covers the whole dataset, so without dropping it accumulates every feature. Fix: add --drop-densest-as-needed; if that still overflows, set an explicit --drop-rate or give unimportant features a higher minzoom so they never enter the coarse tiles.
2. Coordinate quantization artifacts.
Symptom: smooth coastlines and curved roads look like staircases at low zoom. Geometry is snapped to the integer tile-extent grid, and at coarse zoom one grid step is hundreds of metres. Fix: increase the extent on affected zooms with --detail, pre-simplify with a tolerance matched to the zoom’s ground resolution, or accept the snapping as correct at that scale and rely on higher zooms for fidelity.
3. Feature-drop surprises.
Symptom: a landmark polygon present in the source is missing from the tileset. Dropping and tiny-polygon reduction are silent. Fix: protect features with a per-feature minzoom, add --no-tiny-polygon-reduction, or use --coalesce to merge rather than discard. Diff per-zoom feature counts by exploding the tileset with mbutil or querying the tiles table to locate where the geometry vanishes.
4. CRS not EPSG:3857 (or 4326) as the tool expects.
Symptom: the tileset is empty, or all features cluster in one wrong tile. Tippecanoe read projected metres as if they were degrees, or ogr2ogr’s MVT writer received geographic degrees. Fix: reproject explicitly — EPSG:4326 into Tippecanoe, EPSG:3857 into GDAL’s MVT driver — and assert gdf.crs.to_epsg() before tiling rather than trusting the source file’s declared CRS.
5. Attribute type coercion in MVT.
Symptom: an integer ID reads back as a float, or a boolean becomes a string, in the decoded tile. MVT’s value encoding is narrower than a GeoDataFrame’s dtypes, and GeoJSON staging can widen ints to floats. Fix: cast columns to explicit types before tiling and keep IDs as strings if exact round-tripping matters, then confirm with a mapbox_vector_tile.decode on a sample tile.
Conclusion
An automated MVT pipeline is fundamentally an exercise in respecting the tile pyramid: geometry is quantized to a fixed integer extent, tile count quadruples with every zoom, and detail must be dropped and simplified downward so that each tile stays inside a workable byte budget. Building the pipeline in code — reproject to the correct CRS, choose a deliberate zoom range, let Tippecanoe handle zoom-dependent dropping and simplification, then validate the MBTiles against an explicit size budget — turns tile generation from a fragile one-off command into a reproducible step you can run in CI or a batch queue. From here the natural progression is a concrete, end-to-end walkthrough of the most common ingestion case, generating MBTiles from a GeoPackage with Tippecanoe, which drills into the flags and verification steps for that specific source format.
FAQ
Why are my low-zoom vector tiles larger than the high-zoom ones?
A single low-zoom tile covers the entire dataset’s footprint, so without feature dropping every feature is quantized into that one tile. Use Tippecanoe’s --drop-densest-as-needed or an explicit --drop-rate so the densest features are thinned at low zoom; the per-tile byte budget stays under roughly 500 KB while high-zoom tiles keep full detail because each covers a tiny area.
What causes staircase or wobble artifacts on lines in vector tiles?
MVT geometry is quantized to an integer grid of the tile extent, 4096 units square by default. At low zoom one integer step spans many metres on the ground, so smooth curves snap to the grid and look like staircases. Raise the extent with --detail (for example 12 gives 4096, 13 gives 8192) on the affected zooms, or reproject and pre-simplify so vertices already sit near grid points.
Do I need to reproject to EPSG:3857 before tiling? Tippecanoe assumes EPSG:4326 input and internally maps longitude and latitude to the Web Mercator tile pyramid, so it accepts geographic coordinates. GDAL’s MVT driver, however, expects data already in the tiling CRS. If you feed projected metres to Tippecanoe as if they were degrees, or geographic degrees to ogr2ogr’s MVT writer, features land in the wrong tiles. Standardize on EPSG:3857 for the GDAL path and EPSG:4326 for the Tippecanoe path.
Why did some features disappear entirely from my tileset?
Feature dropping is silent by default. --drop-densest-as-needed removes whole features to meet the size budget, and tiny polygons below one pixel at a given zoom are discarded during simplification. Add --no-tiny-polygon-reduction to keep small areas, use --coalesce to merge instead of drop, or attach a minzoom per feature so important geometry survives. Compare feature counts per zoom with tile-join --overzoom to find where they vanish.
Related
- Generating MBTiles from GeoPackage with Tippecanoe — a focused, end-to-end walkthrough of the GeoPackage ingestion case, flag by flag
- Generalization and Simplification — the geometry-thinning theory that zoom-dependent tiling depends on
- Tile Cache and Invalidation — keeping served tiles fresh once the pipeline ships them to a CDN