This guide is part of Storing Spatial Data in Cloud Object Storage, within the broader Orchestrating Spatial ETL Pipelines reference.

Partitioning GeoParquet by Region and Date

Partitioning decides what a query can skip without reading, and spatial sorting decides what it can skip within the files it does read. The two together are the difference between a lake that answers a regional question in seconds and one that scans a terabyte to do it.

Why the Default Write Prunes Badly

  • One file per batch fragments the dataset. Hourly writes across forty regions produce hundreds of thousands of small objects in a year.
  • Unsorted rows defeat row-group statistics. Every group’s bounding box covers the whole extent, so a bbox filter cannot skip anything.
  • Append-style writes duplicate on retry. A re-run adds a second copy of the partition, and both are read.
  • Partitioning on a fine key explodes the object count. A partition per tile per day is correct pruning and unusable metadata.

Version and Environment Compatibility

Component Version Note
pyarrow >=14 write_dataset, existing_data_behavior, row-group control
GeoPandas >=1.0 GeoParquet 1.0 metadata, to_arrow round-trip
shapely >=2.0 Vectorized bounds and centroid for the sort key
numpy >=1.26 Morton index computation
pip install "geopandas>=1.0" "pyarrow>=14" "shapely>=2.0" "s3fs>=2024.6"

What Pruning Actually Skips

Two levels of pruning on one query A query for one region and one month against a two terabyte dataset. Partition pruning uses the Hive path values to eliminate all but three files without opening them. Row-group statistics inside those files then eliminate most groups because the rows were spatially sorted before writing. The result is a few hundred megabytes read from a two terabyte dataset. 2 TB dataset 2 400 objects partition pruning path values → 3 files row-group pruning bbox stats → 11 groups only possible because the rows were sorted spatially before the write Partition pruning needs no data read at all; row-group pruning costs one metadata read per file.

The write_geoparquet_partitioned Recipe

from __future__ import annotations

import logging

import geopandas as gpd
import numpy as np
import pyarrow as pa
import pyarrow.dataset as ds
import shapely

logger = logging.getLogger(__name__)


def morton_key(gdf: gpd.GeoDataFrame, bits: int = 16) -> np.ndarray:
    """Interleave scaled x and y so spatially close features sort together."""
    centroids = shapely.centroid(gdf.geometry.values)
    xs = shapely.get_x(centroids)
    ys = shapely.get_y(centroids)

    span = max(xs.ptp() or 1.0, ys.ptp() or 1.0)
    scale = (2 ** bits - 1) / span
    xi = ((xs - xs.min()) * scale).astype(np.uint64)
    yi = ((ys - ys.min()) * scale).astype(np.uint64)

    key = np.zeros_like(xi)
    for bit in range(bits):
        key |= ((xi >> bit) & 1) << (2 * bit)
        key |= ((yi >> bit) & 1) << (2 * bit + 1)
    return key


def write_geoparquet_partitioned(
    gdf: gpd.GeoDataFrame,
    root: str,
    region: str,
    period: str,
    filesystem=None,
    rows_per_group: int = 200_000,
    max_rows_per_file: int = 2_000_000,
) -> None:
    """Write one partition of a GeoParquet dataset, spatially sorted and replaceable."""
    if gdf.empty:
        logger.warning("nothing to write for region=%s period=%s", region, period)
        return
    if gdf.crs is None:
        raise ValueError("refusing to write a partition with no CRS")

    ordered = gdf.iloc[np.argsort(morton_key(gdf), kind="stable")].copy()
    ordered["region"] = region
    ordered["period"] = period

    table = ordered.to_arrow()   # carries the GeoParquet metadata through
    ds.write_dataset(
        table,
        base_dir=root,
        filesystem=filesystem,
        format="parquet",
        partitioning=["region", "period"],
        partitioning_flavor="hive",
        existing_data_behavior="delete_matching",
        max_rows_per_file=max_rows_per_file,
        min_rows_per_group=rows_per_group // 2,
        max_rows_per_group=rows_per_group,
        file_options=ds.ParquetFileFormat().make_write_options(
            compression="zstd", compression_level=3, write_statistics=True,
        ),
    )
    logger.info("wrote %d rows to region=%s/period=%s", len(ordered), region, period)

Key Implementation Notes

  • The Morton sort is what makes row-group statistics useful. Without it, every group’s bounding box spans the partition and no group can be skipped; with it, a bbox query typically reads a small minority of groups.
  • existing_data_behavior="delete_matching" replaces the partition on a re-run. This is the idempotency guarantee, and omitting it is the most common cause of duplicated rows in a lake.
  • Row groups of ~200 000 rows keep statistics granular without inflating the footer. Very small groups make the metadata large; very large ones make pruning coarse.
  • write_statistics=True is explicit because a file written without statistics cannot be pruned at all, and the flag has defaulted differently across Arrow versions.
  • The CRS check comes before any work. A partition written without a CRS is unusable and will be discovered by a consumer rather than by the writer.
  • ZSTD level 3 is a deliberate middle: level 1 is barely smaller than uncompressed for geometry, and levels above 6 cost write time out of proportion to the saving.
Row-group bounding boxes before and after sorting On the left, four row groups of an unsorted file each have a bounding box spanning the entire partition extent, so a query window overlaps all four. On the right, the same rows sorted on a Morton curve give each group a compact, mostly disjoint box, so the same query window overlaps only one group and the other three are skipped without reading. unsorted · every group spans the extent query 4 of 4 groups read Morton-sorted · compact boxes query 1 of 4 groups read

Choosing the Period Granularity

The date component of the partition key trades pruning against object count, and the right answer follows from how much data one period holds.

Choosing the period from volume, not cadence Three daily volumes mapped to the period granularity that keeps partitions in the hundreds of megabytes to low gigabytes. Fifty megabytes a day suits monthly partitions. One gigabyte a day suits weekly. Ten gigabytes a day suits daily. In every case the pipeline may still run hourly, writing into the open partition and compacting when the period closes. daily volume → period → partition size 50 MB / day month ≈ 1.5 GB per partition 1 GB / day week ≈ 7 GB per partition 10 GB / day day ≈ 10 GB per partition An hourly pipeline does not need hourly partitions — it needs hourly writes into the open one, and compaction when it closes.

Aim for partitions in the hundreds of megabytes to low gigabytes. A dataset producing 50 MB a day partitions well by month; one producing 10 GB a day partitions by day. The mistake is picking the granularity from the update cadence rather than from the volume: a pipeline that runs hourly does not need hourly partitions, it needs hourly writes into a daily or monthly partition, with compaction closing the partition when the period ends.

Where a dataset spans both extremes — dense in cities, sparse elsewhere — a single global granularity will be wrong somewhere. Partitioning by region first means each region’s partition size follows its own density, which is usually enough to keep every partition in a sensible range without per-region configuration.

Troubleshooting Partitioned Writes

Symptom Likely cause Fix
Duplicate rows after a re-run Append semantics on the partition existing_data_behavior="delete_matching"
bbox queries read every file Rows unsorted; statistics useless Sort on a Morton or Hilbert key before writing
Dataset lists slowly Too many small files per partition Raise max_rows_per_file; compact completed partitions
CRS missing on read Written through Arrow without GeoParquet metadata Convert with GeoDataFrame.to_arrow, not a bare table
Pruning ignored by the reader Filter expressed on a column not in the partitioning Filter on the partition columns by their Hive names
Files far larger than expected Row groups too large to compress well Reduce max_rows_per_group toward 200 000

Integration Note

One task writes one partition: the same alignment used throughout storing spatial data in cloud object storage, and the reason a retry is safe. Where the orchestrator has its own partition concept, use the same keys so the storage layout and the run history describe the same units — a Dagster partition key of region|period maps directly onto this write with no translation, and a backfill of one cell rewrites exactly one prefix.

Evolving the Schema Without Rewriting History

A partitioned dataset outlives the schema it started with. Three changes come up repeatedly, and only one of them is expensive.

Adding a column is free. Parquet readers tolerate files that lack a column the schema declares, returning nulls for the older partitions. Write the new column going forward and backfill only if the historical values genuinely exist.

Widening a type — int32 to int64, float32 to float64 — is usually free on read and worth doing at a partition boundary so that no single partition mixes both. Narrowing is not safe and should be treated as a new column.

Renaming or removing a column breaks every consumer reading by name. Where it is unavoidable, write both names for a deprecation period, announce the removal with the dataset metadata, and drop the old one only after the consumers have moved.

The general principle is that partitions are immutable once written. A change that requires rewriting history is a new dataset version, published under a new prefix, rather than an edit in place — which keeps the old version readable while consumers migrate and makes the rollback trivial.