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 — no tile server. It is for anyone whose GeoJSON has grown too large to inline and who wants cloud-native, server-less tiles. It sits under Vector Tile Pipelines with PMTiles in Web Mapping & Interactive Visualization.
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 operational cost. PMTiles packs the whole tile pyramid into one file with an internal index, and clients fetch only the byte ranges for the tiles in view. The build, though, has a sharp edge: tippecanoe ingests GeoJSON or FlatGeobuf, not GeoParquet directly, so you convert formats with ogr2ogr first. Skipping that step, or feeding tippecanoe a projected CRS, is where most pipelines fail.
GeoParquet is the right source format because it preserves CRS metadata, compresses well, and is the analytical store used in Cloud-Native Geospatial Formats — tiles are just a derived rendering view of it.
Prerequisites
tippecanoe(system binary; WSL2/container on Windows)gdal>=3.8forogr2ogr(GeoParquet → GeoJSONSeq)pmtiles>=3.2Python package (conversion + verification)geopandas>=0.14for source prep
conda install -c conda-forge "gdal=3.8.*" "pmtiles=3.2.*" "geopandas=0.14.*"
# tippecanoe: brew install tippecanoe (macOS) or build felt/tippecanoe (Linux)
Step-by-Step Implementation
1. Prepare the GeoParquet source in EPSG:4326. Do metric work before this step.
import geopandas as gpd
# building_footprints analysed in EPSG:25832 (metric) upstream
building_footprints = gpd.read_file("building_footprints.gpkg")
building_footprints["area_m2"] = building_footprints.geometry.area
building_footprints = building_footprints.to_crs(epsg=4326)
building_footprints[["building_id", "area_m2", "geometry"]].to_parquet(
"buildings_4326.parquet"
)
2. Convert GeoParquet to line-delimited GeoJSON with ogr2ogr. Tippecanoe streams this efficiently.
import subprocess
subprocess.run([
"ogr2ogr", "-f", "GeoJSONSeq", "buildings.geojsonl", "buildings_4326.parquet",
], check=True)
3. Tile with tippecanoe. Let it pick max zoom and shed density where tiles overflow.
subprocess.run([
"tippecanoe",
"-o", "buildings.mbtiles",
"-zg", # auto max zoom
"--coalesce-densest-as-needed", # merge features in crowded tiles
"--extend-zooms-if-still-dropping",
"-l", "buildings", # layer name MapLibre will reference
"buildings.geojsonl",
], check=True)
4. Convert MBTiles to PMTiles.
subprocess.run(["pmtiles", "convert", "buildings.mbtiles", "buildings.pmtiles"], check=True)
5. Reference it from MapLibre. Register the protocol, then add a vector source.
# In the page JS:
# const protocol = new pmtiles.Protocol();
# maplibregl.addProtocol("pmtiles", protocol.tile);
# map.addSource("buildings", {
# type: "vector",
# url: "pmtiles://https://cdn.example.com/buildings.pmtiles",
# });
# map.addLayer({ id: "bld", type: "fill", source: "buildings",
# "source-layer": "buildings",
# paint: { "fill-color": "#3e5c76", "fill-opacity": 0.7 } });
Verification
Inspect the PMTiles header to confirm zoom range, bounds, and the layer name before deploying.
from pmtiles.reader import Reader, MmapSource
with open("buildings.pmtiles", "rb") as fh:
reader = Reader(MmapSource(fh))
header = reader.header()
print("min/max zoom:", header["min_zoom"], header["max_zoom"]) # 0 14
print("bounds (E7):", header["min_lon_e7"], header["min_lat_e7"])
meta = reader.metadata()
print("layers:", [layer["id"] for layer in meta["vector_layers"]]) # ['buildings']
assert header["max_zoom"] >= 12, "Max zoom too low for street-level detail"
The reported bounds, converted from E7 integers, should match your dataset's extent — a wildly wrong box means the source wasn't in EPSG:4326.
Edge Cases & Debugging
ogr2ogrcan't read Parquet. Your GDAL build lacks the Parquet driver; installgdalfrom conda-forge, which includes it.- Tiles placed wrong. Source wasn't EPSG:4326 before tiling — reproject and rebuild.
source-layermismatch. The MapLibre layer'ssource-layermust equal tippecanoe's-lvalue (buildingshere), not the source name.- Attributes dropped. Tippecanoe trims attributes under a size budget; keep specific fields with
-y building_id -y area_m2. - PMTiles 403 on the bucket. The host must allow HTTP range requests and CORS; enable both on the object store.
- Still too large. Cap
--maximum-zoomand add--drop-smallest-as-neededto bound tile size.