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.

One feature in GeoParquet becomes many copies in a tileset On the left, a single parcel polygon stored once in a GeoParquet file with all of its attribute columns. On the right, the same parcel rendered at three zoom levels: at zoom twelve it falls inside one tile, at zoom thirteen it straddles two, and at zoom fourteen it straddles four, so the tileset holds seven copies of its geometry and seven copies of every attribute carried into the tiles. A note gives the arithmetic for an unused forty-byte string column across twelve million features. GeoParquet — stored once 1 parcel row geometry (WKB) 18 attribute columns column pruning at read time means unused columns cost nothing Tileset — stored once per tile, per zoom zoom 12 1 copy zoom 13 2 copies zoom 14 4 copies Seven copies of the geometry — and seven copies of every attribute carried into the tiles. One unused 40-byte string column × 12 M features ≈ 480 MB in GeoParquet, ≈ 3.8 GB across the tileset. This is why pruning attributes before tiling beats every compression setting available afterwards.

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

python
# 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.

bash
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.

Two ways to fit a crowded tile inside its byte budget Three panels of the same crowded block of polygons. The first shows the source at full detail, over the tile byte budget. The second shows geometry truncation: every polygon survives but each is coarsened, and two rings are left visibly unclosed with spikes at their edges. The third shows feature dropping: the densest polygons are removed and the survivors keep their exact outlines, so the tile reads as a cleaner but sparser version of the source rather than a damaged one. Source — over budget 1.9 MB — must shed something Truncate geometry every shape degraded, two rings unclosed Drop densest features fewer shapes, every survivor exact A sparser map reads as a design choice; a map of broken polygons reads as a bug. Dropping also restores itself at higher zooms, where the budget is no longer under pressure. What a tiling job is actually bound by Resource utilisation over the course of a tile build. CPU sits well below saturation for most of the run. Memory rises steadily as the tiler accumulates per-zoom state. Scratch disk input and output is pinned at the device limit for almost the entire job. The conclusion is that a tiling machine should be sized for memory and fast local scratch, not for core count. Resource utilisation across one tile build device / core limit CPU — never the bottleneck memory — rises all run scratch disk I/O — pinned at the limit Size the build machine for memory and local NVMe scratch. Extra cores sit idle.

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.


← Back to PMTiles and Cloud-Native Tile Archives