Converting GeoParquet to PMTiles with Tippecanoe
Prune attributes, derive the zoom range from the data, prefer dropping features to mangling geometry, and write the archive in one pass. Those four decisions account for nearly all the variance in build time, archive size, and visual quality; everything else in the tiler’s very large option surface is a refinement. This page is the build step behind PMTiles and cloud-native tile archives, taking analytical GeoParquet as the input.
Quick Reference
| Decision | Setting | Effect | Primary Use Case |
|---|---|---|---|
| Attribute pruning | select only rendered columns | Largest single size lever; attributes duplicate per tile per zoom | Every build |
| Minimum zoom | from the layer’s extent | Avoids generating tiles of an empty world | City or region layers |
| Maximum zoom | from real vertex density | Avoids zooms that add no detail the source contains | Coarse boundary layers |
| Overflow policy | --drop-densest-as-needed |
Keeps polygons closed; thins dense areas instead | Polygon layers at low zoom |
| Simplification | --simplification 4 |
Moderate vertex reduction per zoom | General-purpose cartography |
| Output | direct to .pmtiles |
Avoids materialising millions of small files | Every build |
Why Tiles Are Bigger Than the Source
A GeoParquet file stores each feature exactly once. A tileset stores each feature once per tile it touches, per zoom level at which it is drawn. A parcel that straddles four tiles at zoom 14 and remains visible from zoom 10 upward is written roughly twenty times across the archive. This duplication is what makes tiles fast to serve and what makes attribute pruning the highest-leverage step in the build.
The arithmetic is worth internalising: a string column averaging 40 bytes per feature, on twelve million features, is about 480 MB in GeoParquet. In a tileset with an average duplication factor of eight, it is nearly 4 GB before compression. Six such columns that nobody renders are most of the archive.
The second lever is what happens when a tile exceeds its byte budget. The tiler must shed something, and the choice of what is a cartographic decision disguised as a flag. Truncating geometry keeps every feature but degrades shapes, and on polygons it can leave rings unclosed — visible as gaps and spikes at low zoom. Dropping the densest features keeps the surviving shapes intact and thins crowded areas, which is almost always the better-looking outcome and the reason --drop-densest-as-needed is the right default for polygon layers.
Building the Archive
# Requires: geopandas>=1.0, pyarrow>=16; tippecanoe>=2.60 on PATH (Python 3.10+)
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import geopandas as gpd
def prune_and_stream(source: Path, keep: tuple[str, ...], target: Path) -> int:
"""Write newline-delimited GeoJSON carrying only the rendered attributes.
Streaming avoids materialising a multi-gigabyte intermediate; the columns
are pruned here because every attribute is duplicated into every tile.
"""
frame = gpd.read_parquet(source, columns=[*keep, "geometry"])
if frame.empty:
raise ValueError(f"{source} has no features to tile")
if frame.crs is None or frame.crs.to_epsg() != 4326:
raise ValueError("tiling requires EPSG:4326 — reproject before this step")
written = 0
with open(target, "w", encoding="utf-8") as handle:
for feature in json.loads(frame.to_json())["features"]:
handle.write(json.dumps(feature, separators=(",", ":")) + "\n")
written += 1
return written
def build(
ndjson: Path,
archive: Path,
layer: str,
*,
min_zoom: int,
max_zoom: int,
simplification: int = 4,
) -> Path:
"""Run the tiler straight into a PMTiles archive."""
if not 0 <= min_zoom <= max_zoom <= 20:
raise ValueError(f"implausible zoom range {min_zoom}-{max_zoom}")
cmd = [
"tippecanoe",
"-o", str(archive), "--force",
"--layer", layer,
"--minimum-zoom", str(min_zoom),
"--maximum-zoom", str(max_zoom),
# Shed whole features rather than truncating geometry, so polygons
# stay closed at every zoom.
"--drop-densest-as-needed",
"--simplification", str(simplification),
# Keep the tiler from inventing attributes we then pay to store.
"--no-tile-stats",
str(ndjson),
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=10800)
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"tippecanoe failed: {exc.stderr.strip()[:400]}") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("tiling exceeded its three-hour budget") from exc
return archive
Validation
The archive is sound when no single tile is unusually large and the per-zoom counts follow the shape of the data rather than of the world.
pmtiles show parcels.pmtiles
# tile type: mvt · min zoom 8 · max zoom 14 · 1,884,102 tiles
# largest tile: 412 KB at 14/8192/5461
# Per-zoom sanity: counts should roughly quadruple per level within the layer's
# extent. A level that jumps by far more than 4x is tiling empty world.
pmtiles show --header-json parcels.pmtiles | python3 -m json.tool | head -20
Healthy ranges: the largest tile should sit under about 500 KB, and typical tiles well under 100 KB. A single 4 MB tile means one dense area is overwhelming its budget — usually a city centre in a national layer — and the fix is a lower feature threshold at that zoom, not a global simplification increase that damages the whole map.
Edge Cases and Caveats
Mixed geometry types in one layer. A GeoParquet column holding points, lines, and polygons produces a tileset whose styling rules must branch on geometry type, which most style specifications handle awkwardly. Split into separate layers within the archive — the tiler supports several — rather than shipping one heterogeneous layer, and see mapping mixed geometry types for the upstream decision.
Invalid geometry surviving into the tiler. Self-intersecting polygons tile without error and render as visual artefacts that look like styling bugs. Validate before tiling, not after seeing the map — the same discipline the conversion pipeline applies to null and schema handling.
Build machines sized for the wrong resource. Tiling is memory- and IO-bound rather than CPU-bound at the scales that matter; a machine with many cores and modest memory will thrash while a machine with fewer cores and generous memory finishes comfortably. Size for the working set, and prefer local NVMe scratch space to network storage for the tiler’s temporary files.
Frequently Asked Questions
Why is my tile archive so much larger than the GeoParquet it came from?
Because tiles duplicate. A feature that spans several tiles is written into each of them, and it appears again at every zoom level that renders it, so one polygon can be stored a dozen times. Attributes duplicate the same way, which is why pruning columns before tiling has an outsized effect: dropping six unused string columns from a twelve-million-feature layer routinely removes more from the archive than raising the compression level ever will.
What is the difference between dropping features and simplifying geometry?
Simplification removes vertices from a shape while keeping the shape; dropping removes whole features while keeping the rest intact. At low zooms both are necessary because a tile has a byte budget. Dropping the densest features preserves the visual character of what remains, whereas aggressive simplification applied everywhere degrades every shape at once and can leave polygons self-intersecting or visibly unclosed.
Can I tile directly from GeoParquet without converting to GeoJSON first?
Recent tiler builds read GeoParquet and FlatGeobuf directly. Where a build does not, stream newline-delimited GeoJSON into the tiler through a pipe rather than materialising an intermediate file: a twelve-million-feature layer becomes tens of gigabytes of JSON on disk, and writing then re-reading it frequently costs more wall clock than the tiling itself.
Related
- PMTiles and Cloud-Native Tile Archives — parent guide: what the archive is and when it is the right delivery format
- Serving Vector Tiles from Object Storage with Range Requests — publishing the archive once it is built
- Simplifying Geometries Before Compression — vertex reduction as a storage decision rather than a cartographic one
- Newline-Delimited GeoJSON for Streaming Pipelines — the streaming intermediate this build uses
← Back to PMTiles and Cloud-Native Tile Archives