Automated Generation Pipelines with Tippecanoe
Tippecanoe converts GeoJSON, GeoParquet, and other spatial formats into Mapbox Vector Tiles (MVT) across a configured zoom range, applying feature dropping, geometry simplification, and attribute filtering to keep each tile under the 500 KB budget. Automating this process — wiring Tippecanoe into repeatable, version-controlled workflows — removes the operational overhead of manual regeneration and makes spatial datasets deployable artifacts on the same cadence as application code.
Pipeline Architecture: GeoJSON to CDN
The diagram below maps the canonical five-stage pipeline from raw source data through CDN delivery. Every automated setup, regardless of orchestrator, passes through these stages in order.
Each stage is independently testable. Separating ingestion from tile compilation lets teams run Tippecanoe in parallel across datasets while validating upstream schema changes in isolation.
Core Tippecanoe Flag Taxonomy
The full Tippecanoe CLI flag set is large, but production pipelines rely on a well-defined core group. Misunderstanding these flags is the most common source of oversized tiles or incorrect zoom coverage.
| Flag | Purpose | Typical production value |
|---|---|---|
-z / --maximum-zoom |
Highest zoom level to generate | 14 for urban datasets; 10 for country-scale |
-Z / --minimum-zoom |
Lowest zoom level (default 0) |
0–2 |
-e / --output-to-directory |
Write tiles to a directory instead of MBTiles | Used for PMTiles conversion step |
-o / --output |
Write directly to .mbtiles or .pmtiles |
output.pmtiles |
-l / --layer |
Name the output layer | Must match the style spec layer source-layer |
-y / --include |
Allowlist of attribute columns to retain | Keep only render-relevant fields |
--drop-densest-as-needed |
Drop features at low zoom to meet tile size budget | Enabled for point datasets |
--simplification |
Simplification level in tile coordinate units (default 2) |
2–10 depending on geometry complexity |
--no-tile-size-limit |
Disable the 500 KB hard cap | Use only with PMTiles on CDN range-request delivery |
--force |
Overwrite existing output file | Required in CI where artifacts persist across runs |
--read-parallel |
Process multiple input files in parallel | Speeds up multi-layer builds |
--extend-zooms-if-still-dropping |
Auto-raise max zoom until no features are dropped | Useful for dense datasets where dropping is unacceptable |
For a complete annotated inventory of flags that affect production builds, see Essential Tippecanoe Flags for Production Builds.
Implementation Patterns
Pattern 1: Single-Dataset Bash Pipeline
The simplest reproducible pipeline wraps Tippecanoe in a Bash script that validates inputs, runs conversion, and writes output to a versioned path.
#!/usr/bin/env bash
set -euo pipefail
INPUT="data/admin_boundaries.geojson"
OUTPUT="dist/admin_boundaries_v$(date +%Y%m%d).pmtiles"
LAYER="admin"
MAX_ZOOM=10
# Validate source exists and is non-empty
[ -s "$INPUT" ] || { echo "ERROR: $INPUT missing or empty"; exit 1; }
tippecanoe \
--output="$OUTPUT" \
--layer="$LAYER" \
--minimum-zoom=0 \
--maximum-zoom="$MAX_ZOOM" \
--simplification=4 \
--include=iso_a2 \
--include=name \
--include=admin_level \
--drop-densest-as-needed \
--force \
"$INPUT"
echo "Generated: $OUTPUT ($(du -sh "$OUTPUT" | cut -f1))"
Every flag is explicit — no reliance on defaults that may change across Tippecanoe versions. The versioned filename (_v20260624.pmtiles) enables safe atomic CDN swaps.
Pattern 2: Multi-Layer Python Subprocess Build
When a tileset requires merging several source layers (roads, parcels, buildings), Python’s subprocess module produces a maintainable build script with per-layer configuration:
#!/usr/bin/env python3
import subprocess
import sys
from pathlib import Path
LAYERS = [
{
"input": "data/roads.geojson",
"layer": "roads",
"max_zoom": 14,
"include": ["highway", "name", "oneway"],
},
{
"input": "data/buildings.geojson",
"layer": "buildings",
"max_zoom": 16,
"include": ["height", "building"],
},
{
"input": "data/landuse.geojson",
"layer": "landuse",
"max_zoom": 12,
"include": ["landuse", "name"],
},
]
OUTPUT = Path("dist/composite.pmtiles")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"tippecanoe",
f"--output={OUTPUT}",
"--minimum-zoom=0",
"--simplification=3",
"--drop-densest-as-needed",
"--force",
"--read-parallel",
]
for layer in LAYERS:
cmd += [
f"--layer={layer['layer']}",
f"--maximum-zoom={layer['max_zoom']}",
]
for attr in layer["include"]:
cmd += [f"--include={attr}"]
cmd.append(layer["input"])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(result.stderr, file=sys.stderr)
sys.exit(result.returncode)
size_kb = OUTPUT.stat().st_size / 1024
print(f"Built {OUTPUT} ({size_kb:.0f} KB)")
Passing --read-parallel with multiple input files lets Tippecanoe parallelise I/O across the source datasets. Each layer’s --include list is explicit — dropping unused attributes at build time rather than relying on the style spec to hide them produces measurably smaller tiles.
Pattern 3: GeoParquet Ingestion via DuckDB
For large datasets stored as GeoParquet, streaming through DuckDB avoids loading the full file into memory and integrates spatial filtering before tile generation:
# Stream a bbox-filtered GeoParquet file to Tippecanoe via NDJSON
duckdb -c "
LOAD spatial;
COPY (
SELECT
ST_AsGeoJSON(geometry) AS geometry,
name,
population,
admin_level
FROM read_parquet('data/world_cities.geoparquet')
WHERE ST_Within(geometry, ST_MakeEnvelope(-180, -60, 180, 85))
AND population > 50000
) TO '/dev/stdout' (FORMAT JSON, ARRAY false);
" | tippecanoe \
--output=dist/cities.pmtiles \
--layer=cities \
--minimum-zoom=2 \
--maximum-zoom=12 \
--include=name \
--include=population \
--drop-densest-as-needed \
--force \
-
# Verify output size
python3 -c "
import sqlite3, sys
conn = sqlite3.connect('dist/cities.pmtiles') # pmtiles also readable as sqlite
# For pmtiles use pmtiles CLI instead
"
The - argument instructs Tippecanoe to read NDJSON from stdin. Converting large GeoParquet files to vector tiles covers the full streaming approach including column projection pushdown for datasets exceeding available RAM.
Performance and Scale Considerations
Tile Size Budgets
The 500 KB per-tile limit is the practical ceiling for CDN delivery and browser parsing. Exceeding it degrades initial map load times and may trigger client-side memory pressure on mobile devices.
| Dataset type | Recommended max zoom | Expected tile size at max zoom | Primary control flag |
|---|---|---|---|
| Country/continent polygons | z8–z10 | 50–200 KB | --simplification=6 |
| Admin boundaries (country level) | z10–z12 | 100–400 KB | --simplification=4 |
| Urban road network | z14 | 200–500 KB | --include allowlist |
| Building footprints | z16 | 100–350 KB | --drop-densest-as-needed |
| Point of interest dataset | z14 | 50–150 KB | --drop-densest-as-needed |
At zoom levels where feature density exceeds the tile budget, Tippecanoe drops features by default (--drop-densest-as-needed) or halts with an error if --no-tile-size-limit is not set. For datasets where dropping is unacceptable — parcel boundaries, utility networks — use --extend-zooms-if-still-dropping to automatically raise max-zoom until all features fit.
Zoom level optimization strategies provide quantified guidance on calculating the right --maximum-zoom value for a given dataset’s feature density, including the formula for calculating optimal max zoom for urban datasets.
Memory and CPU Constraints
Tippecanoe holds the full input dataset in memory during the sort phase before tile compilation. For datasets larger than available RAM, use one of these strategies:
# Split large file by geography before merging
ogr2ogr -clipsrc -180 -60 0 85 data/world_west.geojson data/world.geojson
ogr2ogr -clipsrc 0 -60 180 85 data/world_east.geojson data/world.geojson
tippecanoe \
--output=dist/world.pmtiles \
--layer=world \
--maximum-zoom=10 \
--force \
data/world_west.geojson data/world_east.geojson
# Or stream GeoParquet directly (avoids full in-memory load)
# See Pattern 3 above
Containerized CI runners should allocate at minimum 4 GB RAM for city-scale datasets at z14, and 16 GB for national road networks. Set ulimit -v in the CI environment to surface memory exhaustion early with a clear error rather than silent OOM kills.
Geometry Simplification Trade-offs
Tippecanoe’s built-in --simplification applies Douglas-Peucker reduction in tile coordinate space. For datasets with shared boundaries — municipal regions, land-use parcels — pre-processing with ST_SimplifyPreserveTopology in PostGIS preserves adjacency that Douglas-Peucker applied independently per feature will break.
The geometry simplification algorithms reference covers both Tippecanoe’s native approach and how Visvalingam-Whyatt compares to Douglas-Peucker for different geometry types. The short version: Visvalingam-Whyatt preserves visually meaningful vertices better for irregular organic shapes; Douglas-Peucker is faster and Tippecanoe’s default.
Storage and Delivery
MBTiles vs PMTiles
Both formats package tiles into a single file, but their delivery models differ in ways that matter for automated pipelines.
| Property | MBTiles (.mbtiles) | PMTiles (.pmtiles) |
|---|---|---|
| Container | SQLite database | Custom binary archive |
| HTTP delivery | Requires tile server (martin, tileserver-gl) | Direct HTTP range requests from S3/R2/GCS |
| Serverless compatibility | No | Yes |
| Random tile access | Via SQL query | Via byte-range offset index |
| Tooling maturity | High (SQLite ecosystem) | Growing (pmtiles CLI, MapLibre native support) |
| Best for | Local dev, offline apps, PostGIS workflows | CDN delivery, serverless, edge deployments |
For automated pipelines targeting web delivery, PMTiles is the preferred output format. The output is a single file: atomic to upload, trivial to version, and readable by MapLibre GL JS without an intermediate server. The MBTiles architecture and limits reference covers the SQLite concurrency constraints that surface in parallel pipeline runs.
CDN Cache-Control Strategy
Versioned tile artifacts eliminate cache invalidation complexity. Use a datestamp or content hash in the filename, then configure Cache-Control: public, max-age=31536000, immutable on object storage:
# Upload PMTiles to Cloudflare R2 with immutable cache headers
OUTPUT_FILE="dist/admin_v20260624.pmtiles"
R2_BUCKET="my-tiles-bucket"
R2_PREFIX="tiles/admin/v20260624/"
# Using wrangler (Cloudflare R2)
wrangler r2 object put "${R2_BUCKET}/${R2_PREFIX}admin.pmtiles" \
--file="$OUTPUT_FILE" \
--content-type="application/octet-stream" \
--cache-control="public, max-age=31536000, immutable"
# Using AWS CLI (S3-compatible)
aws s3 cp "$OUTPUT_FILE" \
"s3://${R2_BUCKET}/${R2_PREFIX}admin.pmtiles" \
--cache-control "public, max-age=31536000, immutable" \
--content-type "application/octet-stream"
For frequently updated layers (daily sensor data, live transit), use max-age=3600 and a shorter version prefix. Keep the previous version live until client cache TTLs expire to avoid serving a mix of old style specs with new tile data.
CI/CD Integration
GitHub Actions Workflow
The minimal production GitHub Actions configuration runs Tippecanoe in a containerized environment with pinned Tippecanoe version:
name: Generate vector tiles
on:
push:
paths:
- 'data/**/*.geojson'
- 'data/**/*.geoparquet'
schedule:
- cron: '0 3 * * 1' # Weekly Monday 03:00 UTC
jobs:
build-tiles:
runs-on: ubuntu-latest
container:
image: ghcr.io/felt/tippecanoe:latest
steps:
- uses: actions/checkout@v4
- name: Validate inputs
run: |
for f in data/*.geojson; do
python3 -c "import json,sys; json.load(open('$f'))" \
|| { echo "Invalid JSON: $f"; exit 1; }
done
- name: Generate tiles
run: |
tippecanoe \
--output=dist/output.pmtiles \
--layer=features \
--minimum-zoom=0 \
--maximum-zoom=14 \
--include=name \
--include=category \
--drop-densest-as-needed \
--force \
data/*.geojson
- name: Validate tile output
run: |
SIZE=$(stat -c%s dist/output.pmtiles)
echo "Output size: ${SIZE} bytes"
[ "$SIZE" -gt 1000 ] || { echo "ERROR: output suspiciously small"; exit 1; }
- name: Upload to R2
env:
CLOUDFLARE_API_TOKEN: $
run: |
wrangler r2 object put tiles/output.pmtiles \
--file=dist/output.pmtiles \
--content-type="application/octet-stream" \
--cache-control="public, max-age=3600"
Pin the Tippecanoe image tag rather than using latest in production — version drift between the development environment and CI is a frequent source of tile generation inconsistencies.
Pull Request Gating
For teams treating spatial data as code, tile validation on every PR prevents broken tilesets from merging. A lightweight gating job runs a test subset:
#!/usr/bin/env bash
# ci/validate_tiles.sh — run on every PR
set -euo pipefail
SAMPLE_INPUT="data/sample_1000_features.geojson"
TEST_OUTPUT="/tmp/pr_test.pmtiles"
tippecanoe \
--output="$TEST_OUTPUT" \
--layer=test \
--minimum-zoom=0 \
--maximum-zoom=14 \
--include=name \
--force \
"$SAMPLE_INPUT"
# Check max tile size (requires pmtiles CLI)
pmtiles verify "$TEST_OUTPUT"
MAX_TILE=$(pmtiles show "$TEST_OUTPUT" | grep -oP 'max_tile_size: \K[0-9]+')
if [ "$MAX_TILE" -gt 500000 ]; then
echo "ERROR: max tile size ${MAX_TILE} bytes exceeds 500 KB limit"
exit 1
fi
echo "PASS: max tile size ${MAX_TILE} bytes"
Failure Modes and Debugging
Tile Size Overflow
Symptom: Tile z/x/y is too large: NNN bytes or silent feature dropping at unexpected zoom levels.
Diagnosis:
pmtiles show output.pmtiles | grep max_tile
# or for mbtiles:
sqlite3 output.mbtiles \
"SELECT zoom_level, MAX(LENGTH(tile_data)) as max_size \
FROM tiles GROUP BY zoom_level ORDER BY zoom_level;"
Fix: Add --drop-densest-as-needed to allow Tippecanoe to reduce density at overflowing zooms. For datasets where dropping is unacceptable, tighten the --include list first — removing a single high-cardinality text attribute (street addresses, parcel descriptions) often cuts tile sizes by 30–50%.
Projection Mismatch
Symptom: Tiles generate but map renders features in the ocean or at coordinates (0,0).
Diagnosis: Tippecanoe expects EPSG:4326 (WGS 84 decimal degrees) input. Confirm the source CRS:
ogrinfo -al -so data/input.geojson | grep -i 'coordinate system\|proj\|epsg'
Fix: Reproject before passing to Tippecanoe:
ogr2ogr \
-f GeoJSON \
-t_srs EPSG:4326 \
data/reprojected.geojson \
data/input_3857.geojson
Memory Exhaustion on Large Datasets
Symptom: Killed with no error message, or std::bad_alloc during the sort phase.
Diagnosis: Check peak RSS during a test run:
/usr/bin/time -v tippecanoe --output=/tmp/test.pmtiles --layer=test \
--maximum-zoom=8 data/large.geojson 2>&1 | grep "Maximum resident"
Fix options:
- Pre-filter to a tighter bounding box with
ogr2ogr -spat - Split input by geographic region and pass multiple files
- Stream GeoParquet via DuckDB (Pattern 3 above) instead of loading full GeoJSON
- Reduce
--maximum-zoomby 1–2 levels to cut the sort space
Corrupt or Empty Output
Symptom: PMTiles file writes but MapLibre reports source data loading error.
Diagnosis:
pmtiles verify output.pmtiles
pmtiles show output.pmtiles # Check tile count > 0
A tile count of 0 usually means all features were dropped due to --maximum-zoom set lower than the first zoom where features have enough size to appear. Tippecanoe will not error — it silently writes an empty archive. Set --minimum-zoom=0 explicitly and inspect the feature count at z0.
Layer Name Mismatch
Symptom: Tiles load, no features render, no console errors.
Diagnosis: The --layer flag in Tippecanoe must exactly match source-layer in the MapLibre GL style. Confirm:
pmtiles show output.pmtiles | grep -i layer
Compare against the style JSON layers[].source-layer value. This is the most common silent failure in multi-team workflows where data engineers name layers differently from front-end developers.
Topic Index
This section covers the full pipeline, with each topic deepening a specific stage:
Tippecanoe CLI Fundamentals — exhaustive flag reference organized by pipeline stage: input, generalization, zoom control, output format, and performance flags. The essential flags for production builds distills the minimum viable flag set for shipping a first tileset.
GeoParquet Input Processing — cloud-native ingestion patterns using DuckDB and pyarrow. Converting large GeoParquet files to vector tiles covers streaming, spatial filtering, and column pushdown for datasets exceeding available RAM.
Geometry Simplification Algorithms — how Tippecanoe’s simplification works under the hood, when to pre-simplify in PostGIS instead, and a direct comparison of Visvalingam-Whyatt vs Douglas-Peucker for tile generation.
Attribute Filtering Rules — strategies for reducing tile size through disciplined attribute management. Dropping unused attributes to reduce tile size benchmarks the size impact of common attribute types.
CI/CD Tile Build Automation — turning a one-off command into a reproducible, gated pipeline: pinned container builds, source-hashed Makefile targets, a tile-size gate, and automating Tippecanoe builds with GitHub Actions.
Related
- Vector Tile Architecture and Format Fundamentals — the MVT specification, MBTiles vs PMTiles format internals, and zoom level math that underlies every Tippecanoe build.
- PMTiles Specification Deep Dive — byte-range index structure, header fields, and how MapLibre GL JS fetches tiles directly from object storage without a tile server.
- MBTiles Architecture Limits — SQLite page size constraints, maximum tile counts, and resolving SQLite lock contention in parallel generation pipelines.
- Map Styling and Layer Synchronization — wiring Tippecanoe-generated tile layers into MapLibre GL styles, including structuring styles for multi-source tilesets and binding data-driven paint properties to tile attributes.
- Zoom Level Optimization Strategies — quantified guidance on
--maximum-zoomselection based on dataset feature density and target tile size budget. - Tile Serving & CDN Delivery — where the container this section builds gets served and cached: tile servers, PMTiles range requests, cache-control headers, and versioned URL rotation from CI.