Generating PMTiles from GeoParquet

This guide turns a GeoParquet dataset into a single .pmtiles file you can drop on any static bucket and render in MapLibre GL — no tile server, no database. It is for anyone whose GeoJSON has grown too large to inline and who wants cloud-native, server-less vector tiles. It sits under Vector Tile Pipelines with PMTiles in Web Mapping & Interactive Visualization; if you instead need a running endpoint for auth or on-the-fly filtering, see the sibling recipe Serving MBTiles with Python.

Why This Approach / What Goes Wrong

PMTiles solves the "I have a million features and no budget for a tile server" problem. A naive GeoJSON of that size hangs the browser; a traditional tile server adds a process, a database, and operational cost. PMTiles instead packs the whole tile pyramid into one flat file with an internal directory, and clients fetch only the byte ranges for the tiles currently in view over HTTP Range requests. Storage plus egress is the entire bill.

The build has two sharp edges that break most first attempts. The first is a format gap: tippecanoe, the tiler, ingests GeoJSON, line-delimited GeoJSON, or FlatGeobuf — not GeoParquet directly. You bridge that gap with ogr2ogr from GDAL, and if your GDAL build lacks the Arrow/Parquet driver the conversion fails before tiling even starts. The second edge is projection: tippecanoe expects EPSG:4326 (lon/lat) and performs the Web Mercator projection itself. Feed it a projected CRS — a UTM zone, a national grid, or Web Mercator EPSG:3857 — and every feature lands in the wrong place because tippecanoe reads your metres as degrees. Reprojecting to 4326 as the last step, after all metric work is done, is the single rule that keeps tiles aligned.

GeoParquet is the right source format precisely because it preserves CRS metadata, compresses well, and is the analytical store described in Cloud-Native Geospatial Formats. Treat tiles as a derived rendering view of that store — rebuild them from GeoParquet whenever the data changes rather than editing tiles in place.

GeoParquet to PMTiles conversion chain A GeoParquet source, reprojected to EPSG:4326 as the last metric step, is converted by ogr2ogr to line-delimited GeoJSONSeq, tiled by tippecanoe into an MBTiles SQLite pyramid, then packed by pmtiles convert into a single range-readable PMTiles file. A delivery boundary marks where the build ends and static hosting begins, from which MapLibre fetches only the viewport tiles over HTTP Range requests. One source, one static file Metric work stays upstream — reproject to EPSG:4326 as the LAST step GeoParquet analytical store EPSG:4326 GeoJSONSeq line-delimited one feature / line MBTiles SQLite pyramid per-zoom tiles PMTiles single file range-readable ogr2ogr tippecanoe pmtiles convert delivery boundary — static hosting begins Static bucket / CDN — no server process MapLibre fetches only the viewport tiles over HTTP Range requests
Each stage is a subprocess call from Python; reprojecting to EPSG:4326 before tiling and packing PMTiles as one range-readable file are the two decisions that make the rest server-less.

Prerequisites

# Python-side stack from conda-forge keeps GDAL/GEOS/PROJ coherent:
conda install -c conda-forge "gdal=3.8.*" "pmtiles=3.2.*" "geopandas=0.14.*"
# tippecanoe is a native binary, installed separately:
#   macOS:  brew install tippecanoe
#   Linux:  git clone https://github.com/felt/tippecanoe && cd tippecanoe && make -j && sudo make install

Step-by-Step Implementation

1. Prepare the GeoParquet source in EPSG:4326. Do every metric computation — areas, lengths, buffers — before this reprojection, while the data is still in a projected CRS, then convert to 4326 as the final export. Reprojection uses the transformer stack behind Coordinate Systems with PyProj; the to_crs call here is the delivery hand-off, not an analysis step.

import geopandas as gpd

# building_footprints were analysed in EPSG:25832 (UTM 32N, metric) upstream
building_footprints = gpd.read_file("building_footprints.gpkg")
assert building_footprints.crs is not None, "unknown CRS — set it before reprojecting"

building_footprints["area_m2"] = building_footprints.geometry.area  # metric attribute FIRST, in UTM
building_footprints = building_footprints.to_crs(epsg=4326)          # reproject LAST, for delivery

assert building_footprints.crs.to_epsg() == 4326, "tippecanoe requires EPSG:4326"
building_footprints[["building_id", "area_m2", "geometry"]].to_parquet("buildings_4326.parquet")

2. Convert GeoParquet to line-delimited GeoJSON with ogr2ogr. GeoJSONSeq writes one feature per line, which tippecanoe streams instead of loading whole — the difference between a bounded and an unbounded memory footprint on large inputs.

import subprocess

subprocess.run([
    "ogr2ogr",
    "-f", "GeoJSONSeq",
    "buildings.geojsonl",       # newline-delimited GeoJSON, streamed by tippecanoe
    "buildings_4326.parquet",   # GeoParquet source
], check=True)

If the tiling source is a subset of a larger store — one city out of a national dataset, or a join against an attribute table — doing that work in ogr2ogr means materialising the whole thing first. DuckDB reads GeoParquet natively and writes GeoJSONSeq through the same GDAL drivers, so the filter, the join and the format conversion collapse into one query. The engine and its spatial extension are covered in Querying GeoParquet with DuckDB Spatial:

import duckdb

con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
con.execute("""
    COPY (
        SELECT b.building_id, b.area_m2, b.geometry
        FROM read_parquet('buildings/*.parquet') AS b
        JOIN read_parquet('permits.parquet') AS p USING (building_id)
        WHERE b.area_m2 > 40 AND p.status = 'issued'
    ) TO 'buildings.geojsonl' WITH (FORMAT GDAL, DRIVER 'GeoJSONSeq')
""")

The glob reads a partitioned GeoParquet directory without concatenating it, and only the rows that survive the WHERE clause ever become text. On a hive-partitioned store this is routinely an order of magnitude less I/O than converting the whole dataset and letting tippecanoe discard most of it.

3. Tile with tippecanoe. Let it pick the maximum zoom from feature density, and coalesce (merge) rather than drop features where a tile overflows its size budget — the right choice for contiguous polygons like building footprints.

subprocess.run([
    "tippecanoe",
    "-o", "buildings.mbtiles",
    "-zg",                           # guess the max zoom from feature density
    "--coalesce-densest-as-needed",  # merge features in crowded tiles, don't drop them
    "--extend-zooms-if-still-dropping",
    "-y", "building_id",             # whitelist the fields the style/tooltip need
    "-y", "area_m2",
    "-l", "buildings",               # layer name MapLibre's source-layer must match
    "--force",                       # overwrite an existing output
    "buildings.geojsonl",
], check=True)
Choosing a tippecanoe shedding flag by geometry type A tile that exceeds tippecanoe's 500 kilobyte budget branches three ways by geometry type. Contiguous polygons such as building footprints take the coalesce-densest-as-needed flag, which merges neighbouring shapes and loses no feature. Dense point clouds take drop-densest-as-needed, which thins points only in crowded tiles. Mixed small polygons take drop-smallest-as-needed, which discards the least significant shapes. A footer notes that extend-zooms-if-still-dropping keeps adding zoom levels until nothing is dropped, and that fields named with the minus y flag survive the trim regardless of order. What tippecanoe does when a tile overflows Tile exceeds the 500 KB budget tippecanoe must shed something Contiguous polygons building footprints --coalesce-densest-as-needed merges neighbours into one shape, nothing lost Dense point clouds sensor readings --drop-densest-as-needed thins points in the crowded tiles only Mixed small polygons parcel slivers --drop-smallest-as-needed discards the least significant shapes --extend-zooms-if-still-dropping keeps adding zooms until nothing drops -y building_id -y area_m2 survives the trim whatever its order
Dense data always overflows somewhere; the flag you choose decides whether tippecanoe merges, thins or discards, and only whitelisted fields are guaranteed to reach the client.

Two flags change the build's cost rather than its output. -P reads the input in parallel across cores, and it works only on line-delimited input — the reason to prefer GeoJSONSeq over plain GeoJSON even when memory is not a concern. -t relocates the scratch space, which matters because tippecanoe writes intermediate per-zoom files that routinely exceed the input several times over; on a machine whose /tmp is a small tmpfs, a national dataset will fail hours into the build with a disk-full error and no partial output:

subprocess.run([
    "tippecanoe", "-o", "buildings.mbtiles", "-zg",
    "-P",                              # parallel read — requires line-delimited input
    "-t", "/mnt/scratch/tippecanoe",   # temp space; the build can need several × the input
    "--coalesce-densest-as-needed",
    "-y", "building_id", "-y", "area_m2",
    "-l", "buildings", "--force", "buildings.geojsonl",
], check=True)

As a rough envelope on a modern eight-core laptop with an SSD: a few hundred thousand building footprints to zoom 14 takes single-digit minutes and a few gigabytes of scratch; ten million takes an hour or more and tens of gigabytes. Wall-clock time scales with the deepest zoom far more steeply than with feature count, because each extra level roughly quadruples the tiles written — going from --maximum-zoom 14 to 16 is a sixteen-fold increase in tiles, not a 15% one. Decide the real maximum zoom from how far users actually zoom, and cap it explicitly once -zg has told you what it would have guessed.

4. Convert MBTiles to PMTiles. The pmtiles package rewrites the SQLite tile store into the single-file, range-readable archive.

subprocess.run(["pmtiles", "convert", "buildings.mbtiles", "buildings.pmtiles"], check=True)

If the map needs several layers — footprints, parcels, and a street centreline, each with its own zoom range and its own shedding flags — build one MBTiles per layer and merge them with tile-join before converting. That keeps each layer's tiling decisions independent and still produces a single archive for the client to range-read:

subprocess.run([
    "tile-join", "-o", "city.mbtiles", "--force",
    "buildings.mbtiles", "parcels.mbtiles", "streets.mbtiles",
], check=True)
subprocess.run(["pmtiles", "convert", "city.mbtiles", "city.pmtiles"], check=True)

tile-join is also the incremental-rebuild tool: re-tile only the layer whose source changed and re-merge, rather than re-running a multi-hour build for a dataset that was already correct. Each layer keeps the name it was given by -l, so the MapLibre style references them as three source-layer values against one source.

5. Reference it from MapLibre. Register the pmtiles:// protocol shim before constructing the map, then add a vector source whose source-layer equals tippecanoe's -l value. The full client-side pattern lives in MapLibre GL Vector Web Maps.

# In the page JS (not Python):
#   const protocol = new pmtiles.Protocol();
#   maplibregl.addProtocol("pmtiles", protocol.tile);   // register BEFORE new Map()
#   map.addSource("buildings", {
#     type: "vector",
#     url: "pmtiles://https://cdn.example.com/buildings.pmtiles",
#   });
#   map.addLayer({ id: "bld", type: "fill", source: "buildings",
#                  "source-layer": "buildings",          // must equal tippecanoe -l
#                  paint: { "fill-color": "#3e5c76", "fill-opacity": 0.7 } });

Verification

Inspect the PMTiles header and metadata to confirm the zoom range, bounds, and layer name before deploying — a five-line assertion turns a manual click-through into a CI guardrail.

from pmtiles.reader import Reader, MmapSource

with open("buildings.pmtiles", "rb") as fh:
    reader = Reader(MmapSource(fh))
    header = reader.header()
    meta = reader.metadata()

print("min/max zoom:", header["min_zoom"], header["max_zoom"])       # 0 14
print("bounds (lon):", header["min_lon_e7"] / 1e7, header["max_lon_e7"] / 1e7)
print("layers:", [layer["id"] for layer in meta["vector_layers"]])   # ['buildings']

assert header["max_zoom"] >= 12, "max zoom too low for street-level detail"
assert meta["vector_layers"][0]["id"] == "buildings", "layer name must match the style source-layer"

Bounds are stored as E7 integers (degrees × 10⁷); divide by 1e7 and confirm the box matches your dataset's real extent. A wildly wrong box — or coordinates near Null Island (0, 0) — means the source was not in EPSG:4326 before tiling.

Header checks prove the archive is well-formed; they do not prove any tile contains features. Pull one tile from the middle of the extent and assert it is non-empty, which catches the build that succeeded while dropping everything:

import math

def lonlat_to_tile(lon, lat, zoom):
    """Web Mercator XYZ tile containing a lon/lat, at a given zoom."""
    n = 2 ** zoom
    x = int((lon + 180.0) / 360.0 * n)
    lat_rad = math.radians(lat)
    y = int((1 - math.asinh(math.tan(lat_rad)) / math.pi) / 2 * n)
    return x, y

centre_lon = (header["min_lon_e7"] + header["max_lon_e7"]) / 2e7
centre_lat = (header["min_lat_e7"] + header["max_lat_e7"]) / 2e7
z = header["max_zoom"] - 2
x, y = lonlat_to_tile(centre_lon, centre_lat, z)

tile = reader.get(z, x, y)
assert tile, f"tile {z}/{x}/{y} is empty at the centre of the extent"
assert tile[:2] == b"\x1f\x8b", "tiles should be gzip-compressed inside the archive"
print(f"tile {z}/{x}/{y}: {len(tile):,} bytes")   # tile 12/2200/1343: 41,208 bytes

The gzip magic bytes are worth asserting because they decide how the archive must be served. Tiles are compressed inside the file, so the object store must hand them over untouched; a host that adds its own Content-Encoding: gzip on top produces tiles the browser decompresses once and the PMTiles shim then fails to decompress again. A tile size in the tens of kilobytes is healthy; one that consistently brushes 500 KB means a shedding flag is doing nothing and the renderer will pay for it on every pan.

Byte layout of a PMTiles archive The archive is one flat file laid out as five consecutive blocks: a 127-byte header, a root directory holding the tile index, a JSON metadata block holding the layer names, leaf directory shards, and finally the tile data block of gzipped vector tile blobs. The reader header call reads the first block, where minimum and maximum zoom and the E7 bounds live; the reader metadata call reads the JSON block, whose vector layers entry must match the style source-layer. At map time the client issues one roughly sixteen kilobyte read for header plus root directory, an optional leaf directory read on large archives, then one HTTP Range read per visible tile. No process serves the file, so the object store only has to honour Range requests and CORS. Inside a single .pmtiles archive reader.header() min/max zoom, E7 bounds reader.metadata() vector_layers to source-layer HTTP Range request one read per visible tile header 127 B root directory tile index JSON metadata layer names leaf directories index shards tile data gzipped MVT blobs, offset + length ① header + root: one ~16 KB read the zoom range and E7 bounds live here ② leaf directory large archives only ③ one range read per tile bytes offset to offset+length No process serves this file — the bucket only has to honour Range and CORS a 403 or a map that never paints means one of those two is switched off
The two blocks the verification snippet inspects sit at the front of the archive; everything after them is fetched one byte range at a time, only for tiles actually on screen.

Edge Cases & Debugging

Frequently Asked Questions

Why convert to GeoJSON at all — can't tippecanoe read GeoParquet? No. Tippecanoe accepts GeoJSON, line-delimited GeoJSON, CSV, and FlatGeobuf, but not GeoParquet or GeoPackage. ogr2ogr is the bridge. For very large inputs, prefer FlatGeobuf (-f FlatGeobuf) over GeoJSONSeq — it streams into tippecanoe without materializing a multi-gigabyte text file.

Should I keep the intermediate MBTiles file? Only if you also serve tiles from a process. PMTiles and MBTiles hold the same pyramid; MBTiles is a SQLite database you query per request (see Serving MBTiles with Python), while PMTiles is the static-hosting form. For a public, read-only map, delete the MBTiles after conversion and keep only the .pmtiles.

How do I bust the CDN cache after rebuilding tiles? Set a long, immutable cache header (Cache-Control: public, max-age=31536000, immutable) and change the filename on each rebuild — buildings.v3.pmtiles — rather than overwriting in place. That way a client mid-session never mixes tiles from two builds.

How do I update one region without rebuilding the whole archive? Tile the changed region on its own, then merge with tile-join, which replaces overlapping tiles from the later input. That is minutes instead of hours for a continental dataset where one metropolitan area changed. The alternative — regenerating from the GeoParquet store every time — stays the right default while the full build is under about ten minutes, because a single deterministic build has no merge order to reason about.

Should the archive be one layer or many? One tippecanoe invocation produces exactly one layer, so the question is really how many builds you run before tile-join. Split when the layers want different treatment: a street network needs deeper zooms than an administrative boundary, and a point layer wants dropping where a polygon layer wants coalescing. Keep them together when they share a zoom range and shedding strategy, since one build is simpler to reason about and produces slightly smaller output through shared tile deduplication.

How much does the whole pipeline cost to run at scale? The build is a one-off CPU and scratch-disk expense — an hour of an eight-core machine and tens of gigabytes of temporary space for a ten-million-feature dataset. Serving costs are storage for one object plus egress for the byte ranges clients actually read, which for a typical session is a few hundred kilobytes across a handful of tiles. There is no per-request compute and no database, which is the entire economic argument for this container over a running tile service.

Do I need Web Mercator (EPSG:3857) anywhere in this pipeline? Never as an input and never for measurement. Tippecanoe projects to the Web Mercator tile grid internally; your job is to hand it EPSG:4326 and to compute any areas or distances in an appropriate projected CRS (a UTM zone, a national grid) beforehand. Measuring in 3857 or 4326 gives distorted numbers.