geodataframe-to-pmtiles

Write PMTiles archives from GeoPandas GeoDataFrames with a Python API.

Installation

Install from PyPI:

pip install geodataframe-to-pmtiles

Writing an archive also requires a native GDAL runtime with the PMTiles driver. Install GDAL separately with conda-forge, Homebrew, or your operating system’s package manager before writing archives.

After installing GDAL, use geodataframe_to_pmtiles.check() or the command below to confirm that the GDAL installation and PMTiles driver work:

python -m geodataframe_to_pmtiles check

Quick start

Write one GeoDataFrame by passing it first and supplying the layer name:

from pathlib import Path

import geopandas as gpd
import geodataframe_to_pmtiles as gpm

points = gpd.read_file("points.geojson")

gpm.write(
    points,
    Path("points.pmtiles"),
    layer="points",
)

Multiple named layers

To write more than one layer, pass a mapping. Its keys become the names stored in the archive:

boundaries = gpd.read_file("boundaries.geojson")

gpm.write(
    {"points": points, "boundaries": boundaries},
    Path("map.pmtiles"),
)

Every input GeoDataFrame needs an explicit CRS. Inputs in any resolvable CRS are reprojected to EPSG:4326 without changing the original frame. Assign a CRS first when a source file does not provide one:

points = points.set_crs("EPSG:4326")

output can be a path or a binary stream. This is useful when an application needs archive bytes instead of a file:

from io import BytesIO

archive = BytesIO()
gpm.write({"points": points}, archive)
archive.seek(0)

gpm.write options

The two positional inputs are layers and output; all other inputs are keyword-only. Use layer with the single-GeoDataFrame form, not the mapping form.

Option

Description

layers

A single GeoDataFrame, or a non-empty mapping of string layer names to GeoDataFrame objects. Every frame must be non-empty and have an explicit, resolvable CRS.

output

A string or Path destination, or a binary-writable stream such as BytesIO. Path output is written to a temporary file and atomically replaces the destination only on success.

layer

Required for a single GeoDataFrame and omitted for a mapping. It must be a non-empty string without null characters.

min_zoom

Archive-wide minimum zoom, from 0 through 22; defaults to 0 and cannot exceed max_zoom.

max_zoom

Archive-wide maximum zoom, from 0 through 22; defaults to 8 and cannot be below min_zoom.

layer_zooms

Optional per-layer zoom overrides; see Per-layer zoom ranges below.

name

Optional tileset name, stored in archive metadata when non-empty. Defaults to an empty string.

description

Optional human-readable description, stored in archive metadata when non-empty. Defaults to an empty string.

attribution

Optional string stored as TileJSON attribution; the default empty string omits that key. Unicode and HTML are preserved. A non-string raises TypeError.

json_fields

None by default, which JSON-encodes every list- or dictionary-valued column. A collection limits that treatment to named columns; other list or dictionary columns raise UnsupportedPropertyTypeError.

on_overflow

"error" by default, which raises TileOverflowError before changing the destination when GDAL reports a tile limit action. "unsafe" warns and writes despite possible dropped features or reduced precision.

simplification

Optional geometry simplification factor in tile-coordinate units (4,096 per tile). The default, None, disables simplification.

Per-layer zoom ranges

Use layer_zooms to assign different minimum and maximum zooms to individual layers within one archive. Omitted keys inherit the archive-wide min_zoom or max_zoom. This is useful when you want contour layers visible at all zooms while restricting a dense data layer to high-zoom tiles only:

gpm.write(
    {"contours": contours_gdf, "data": points_gdf},
    Path("map.pmtiles"),
    min_zoom=0,
    max_zoom=8,
    layer_zooms={
        "contours": {"minzoom": 0, "maxzoom": 8},
        "data": {"minzoom": 7},  # maxzoom inherits archive default (8)
    },
)

The LayerZoomSpec type is a TypedDict with optional "minzoom" and "maxzoom" integer keys:

from geodataframe_to_pmtiles import LayerZoomSpec

spec: LayerZoomSpec = {"minzoom": 7}  # maxzoom omitted → archive default

layer_zooms validation runs before any GDAL object is created. The InvalidLayerZoomError exception is raised when:

  • a key refers to a layer name not present in the layers mapping,

  • a zoom value is not an integer,

  • an effective zoom (after inheriting archive defaults) is outside 0–22, or

  • the effective minimum zoom exceeds the effective maximum zoom.

Archive behavior

PMTiles uses the Web Mercator latitude range (±85.05112877980659°). Features entirely outside that range are warned about and skipped; if no features remain, the writer raises EmptyLayerError. Features that cross the boundary continue to GDAL for clipping.

Errors and limitations

The principal exceptions are MissingCRSError for a GeoDataFrame without a CRS, UnsupportedCRSError or CRSTransformError for reprojection failures, UnsupportedPropertyTypeError for values that cannot be written as MVT properties, EmptyLayerError for empty layers, TileOverflowError for a detected safe-overflow rejection, and InvalidLayerZoomError for invalid layer_zooms entries.

MVT stores a feature in every tile it intersects, so feature counts read back from an archive can be higher than the source count. That duplication is not data loss. GDAL’s tile limits can still be reached by dense data; keep the default overflow policy unless accepting the resulting loss is intentional.

About

Ben Welsh created this module in August 2026 as a spinoff of the Reuters Climate Monitor. GitHub Copilot, an AI-powered coding assistant, helped design, implement, test, and document the project.