Partitioning GeoParquet by Region and Date

This page covers one concrete layout decision: partitioning a GeoParquet dataset on S3 by region and date so that a query for “one region, one month” scans a handful of files instead of the whole lake.

A partitioned lake is one of the load-bearing pieces of the broader location intelligence architecture and data foundations: it is the storage tier every downstream join and dashboard reads from. Partitioning is the difference between a lake that a dashboard can query interactively and one that forces a full scan on every read. A GeoParquet dataset written as Hive-style directories — region=midwest/date=2026-07-01/part-0.parquet — lets any reader that understands partition filters prune entire directories before opening a single file. Combined with the bounding-box statistics Parquet already stores per row group, a well-partitioned lake turns most analytical reads into a few megabytes of I/O rather than gigabytes. The trade-off is granularity: partition too coarsely and you scan too much, partition too finely and you drown in tiny files. This page is about finding the middle.

Prerequisites

  • Python packages: geopandas >= 0.14, pyarrow >= 14, and pandas. GeoParquet writing goes through geopandas.GeoDataFrame.to_parquet, which serializes CRS and geometry-column metadata per the GeoParquet spec. Install with pip install "geopandas>=0.14" "pyarrow>=14" pandas.
  • An S3 bucket configured as a data lake. The bucket, prefix conventions, and access controls come from configuring AWS S3 for geospatial data lakes. Writing partitioned datasets also needs s3fs so pyarrow can address s3:// paths directly.
  • A defined region key. Partition on a low-cardinality, business-meaningful region column (sales region, state group, or market) — not on something high-cardinality like ZIP, which explodes the partition count.
  • A CRS decision made up front. Store geometry in a single interchange CRS (EPSG:4326) and record it in the file metadata. Any customer-location layer that carries PII must be governed before it lands here — see best practices for securing PII in customer location datasets.

Partition and Row-Group Parameters

The layout is governed by a few numbers. Get these right and pruning follows; get them wrong and you either scan too much or hit the small-file problem.

Parameter Typical value Effect
Partition keys region, date Directory levels; each becomes a pushdown predicate.
Region cardinality 4–50 Low-cardinality business regions; avoid ZIP/block-group as a key.
Date granularity daily or monthly Daily prunes tighter but multiplies files; monthly if daily volume is small.
Target file size 128–512 MB The sweet spot for S3 + Parquet; below ~64 MB you enter small-file territory.
row_group_size 50k–150k rows Smaller row groups = finer bbox pruning, more metadata overhead.
Compression zstd (level 3) Good ratio and fast decode for geometry WKB columns.
write_covering_bbox True Writes a per-row bbox column so spatial predicates prune row groups.

The two levers in tension are date granularity and file size. If a region produces only a few thousand rows per day, daily partitioning yields thousands of sub-megabyte files — the small-file problem — where per-file open overhead and S3 request cost dominate the actual read. In that regime, partition monthly and let row groups do the finer pruning. If a region produces hundreds of MB per day, daily partitioning is correct and keeps each file near the target size.

A concrete way to choose: estimate a region’s per-day row count, multiply by the average serialized row size (geometry WKB plus attributes — often 1–4 KB for polygon trade areas), and see where daily volume lands relative to the 128 MB floor. Say a mid-size market emits 20,000 trade-area rows a day at 2 KB each — roughly 40 MB per day. That is below the target file size, so daily partitioning would produce chronically undersized files; monthly partitioning aggregates ~1.2 GB into a handful of right-sized files instead. A high-volume market emitting 400,000 rows a day clears 128 MB comfortably and should stay daily. The rule of thumb: pick the coarsest date granularity that still keeps hot queries selective, then rely on row-group bbox pruning for anything finer.

Row-group sizing is the second-order control. A Parquet file is internally divided into row groups, each carrying its own column statistics — including, with write_covering_bbox=True, a bounding box. A reader skips any row group whose statistics cannot satisfy the query. Smaller row groups (50k rows) prune more precisely but add per-group metadata and slightly slow full scans; larger groups (150k rows) are leaner but coarser. For spatially and temporally selective dashboard queries, 100k is a safe default that keeps both the metadata footprint and the pruning granularity reasonable.

What the writer actually produces on disk The dataset root holds one directory per region, each holding one directory per month, each holding one or more Parquet files plus the dataset-level metadata. A query filtered to the south-east and March opens exactly one leaf directory, and the planner never lists the others. Hive-style keys the query planner can read without opening a file s3://retail-lake/trade_areas/ region=northeast/ region=southeast/ month=2026-01/ · 3 files · 412 MB month=2026-02/ · 3 files · 398 MB month=2026-03/ · 4 files · 501 MB ← the only leaf read region=midwest/ region=west/ _common_metadata · schema and partition keys for the whole dataset Partition values are encoded in the path, so they are not stored in every row — the column disappears from the files and reappears in the frame on read.

Annotated Write and Read-Back

The script below writes a partitioned GeoParquet dataset with the CRS pinned in metadata, then reads it back with a partition filter to prove that pushdown only touches the relevant directories. The comments flag every place CRS or partitioning can silently go wrong.

python
import geopandas as gpd
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.dataset as ds
from pyproj import CRS

STORAGE_CRS = CRS.from_epsg(4326)          # single interchange CRS for the lake
LAKE_ROOT = "s3://retail-geolake/trade_areas"


def write_partitioned(gdf: gpd.GeoDataFrame) -> None:
    """Write a GeoDataFrame as region/date-partitioned GeoParquet.

    CRS is asserted before write and persisted in the GeoParquet metadata so
    every reader recovers it without guessing.
    """
    # 1. Assert the CRS explicitly. Never write geometry with an unknown CRS.
    assert gdf.crs is not None, "GeoDataFrame has no CRS; refusing to write"
    if CRS.from_user_input(gdf.crs) != STORAGE_CRS:
        gdf = gdf.to_crs(STORAGE_CRS)      # normalize to the lake's CRS

    # 2. Derive partition columns. 'region' is low-cardinality business key;
    #    'date' is truncated to the day. Both must be plain columns, not index.
    gdf = gdf.copy()
    gdf["date"] = pd.to_datetime(gdf["captured_at"]).dt.strftime("%Y-%m-%d")

    # 3. Write. geopandas 0.14+ serializes the geometry + CRS into the
    #    GeoParquet 'geo' metadata block automatically. write_covering_bbox
    #    adds a per-row bbox column that enables row-group spatial pruning.
    gdf.to_parquet(
        LAKE_ROOT,
        partition_cols=["region", "date"],   # -> region=.../date=.../*.parquet
        row_group_size=100_000,              # finer bbox pruning within a file
        compression="zstd",
        write_covering_bbox=True,            # spatial predicate pushdown
        index=False,
    )


def read_region_month(region: str, month_prefix: str) -> gpd.GeoDataFrame:
    """Read back ONLY the files for one region and one month via partition
    filters. Pyarrow prunes directories before opening any file."""
    dataset = ds.dataset(
        LAKE_ROOT,
        format="parquet",
        partitioning="hive",                 # parse region=/date= from paths
    )
    # Partition filter: pushed down to directory pruning, not a post-read filter.
    flt = (ds.field("region") == region) & \
          (ds.field("date").cast(pa.string()).starts_with(month_prefix))
    table = dataset.to_table(filter=flt)

    gdf = gpd.GeoDataFrame.from_arrow(table)
    # Recover and re-assert the CRS that was persisted in metadata.
    assert gdf.crs is not None, "CRS not recovered from GeoParquet metadata"
    assert CRS.from_user_input(gdf.crs) == STORAGE_CRS
    return gdf


if __name__ == "__main__":
    df = read_region_month("midwest", "2026-07")
    print(f"rows: {len(df)}  crs: {df.crs.to_epsg()}")

The write_covering_bbox=True flag is what connects partitioning to spatial pushdown. Partition pruning handles region and date; the per-row bounding-box column lets a reader further skip row groups whose bbox does not intersect the query window. Together they mean a spatial-plus-temporal query reads only the geometry it needs. This same partitioned layout is how isochrone contours are stored at scale — isochrone generation and network analysis writes drive-time polygons as partitioned GeoParquet keyed by region and travel-time band for exactly this pruning benefit.

Choosing a row-group size is a trade, not a tuning knob At 16 megabytes per row group a selective query reads little but pays for many requests; at 512 megabytes each request is efficient but a single group must be decoded in full. Between 64 and 128 megabytes the scan time is lowest and the reader's peak memory stays modest, which is why that band is the usual default. Row groups: too small costs requests, too large costs memory row group requests per scan peak reader memory selective scan 16 MB 2,940 low 18.2 s 64 MB 760 moderate 6.4 s 128 MB 390 moderate 6.1 s 512 MB 98 high — one group decoded whole 11.7 s Statistics are stored per row group, so a smaller group prunes more precisely — right up to the point where the request overhead of fetching thousands of them swamps the saving. Measure on your own access pattern: a whole-partition scan and a point lookup pull the optimum in opposite directions, and the write is where the choice is made.

Failure Modes and Debugging

Symptom Cause Fix
Thousands of tiny files Daily partitioning on low-volume regions Coarsen to monthly, or compact with a periodic rewrite job.
Reader reports “no CRS” Geometry written via a path that dropped the geo metadata Always write through GeoDataFrame.to_parquet; verify the geo key exists in file metadata.
Query scans the whole lake Filtering on a non-partition column, or filter not pushed down Filter on region/date fields; use dataset.to_table(filter=…), not a post-read .query().
One partition 100x larger than others Skewed region key (one dominant market) Add a sub-partition (e.g. sub-region) or salt the hot region across files.
Spatial filter reads every row group write_covering_bbox was False Rewrite with the bbox column enabled; older writers lack it.
CRS is EPSG:4326 but distances wrong downstream Consumer measured area/length in degrees Reproject to an equal-area CRS after reading, before any metric op.

Two failures dominate in practice. The small-file problem is insidious because it does not error — the lake works, just slowly and expensively, as S3 request counts balloon. Monitor the file-count-to-data-ratio and compact when a partition holds many sub-64-MB files. The second is CRS not persisted: if geometry is ever written through a code path that bypasses GeoParquet’s metadata (for example, dumping WKB into a plain Parquet column), the CRS is lost and every downstream reader is guessing. The assertion in read_region_month is there precisely to fail loudly when that has happened upstream.

Compacting Small Partitions

When a low-volume region has already accumulated many undersized files — because it was partitioned daily before the volume was understood — the remedy is a compaction pass that reads a partition’s fragments and rewrites them as one right-sized file. Run it as a periodic maintenance job, not on the write path, so it never blocks ingestion.

python
def compact_partition(region: str, month_prefix: str) -> None:
    """Rewrite one region-month's many small files into one compacted file.
    CRS is re-asserted so compaction never silently drops the projection."""
    gdf = read_region_month(region, month_prefix)     # partition-filtered read
    assert CRS.from_user_input(gdf.crs) == STORAGE_CRS
    gdf.to_parquet(
        f"{LAKE_ROOT}/region={region}/date={month_prefix}",
        row_group_size=100_000,
        compression="zstd",
        write_covering_bbox=True,
        index=False,
    )

Compaction trades a periodic rewrite cost for a permanent read-latency win. Schedule it against partitions whose file count crosses a threshold, and delete the superseded fragments in the same transaction so readers never see a mix of old and new.

Skew: the partition scheme that looked balanced on paper Partitioning by region gives the west 612 megabytes in four files and the northeast 1.9 gigabytes in nine, but the mountain region only 12 megabytes spread across 47 tiny files written by nightly appends. The tiny-file partition is slower to read than the largest one. The smallest partition is the slowest one to read northeast 1.9 GB · 9 files southeast 1.3 GB · 6 files west 612 MB · 4 files mountain 12 MB · 47 files — 260 KB each, one per nightly append Forty-seven requests to read twelve megabytes costs more wall-clock than nine requests to read 1.9 gigabytes. The fix is not a different partition key — it is a compaction job that rewrites the leaf. Watch file count per partition, not just partition size: skew shows up in the count long before anyone notices the bytes.

Verification

Confirm the layout does what it claims before trusting it:

Partition count is sane. List the prefixes and count region=*/date=* directories. It should match regions × active dates, not balloon into the tens of thousands. Pushdown reads only relevant files: inspect which fragments a filtered read touches — it must be a small subset, not the whole set.

python
import pyarrow.dataset as ds

dataset = ds.dataset(LAKE_ROOT, format="parquet", partitioning="hive")
fragments = list(dataset.get_fragments())
print(f"partition files: {len(fragments)}")

flt = ds.field("region") == "midwest"
touched = list(dataset.get_fragments(filter=flt))
print(f"files scanned for one region: {len(touched)} of {len(fragments)}")

The remaining checks are quick to assert:

  • CRS round-trips. After reading back, gdf.crs.to_epsg() returns 4326; the assertion in the reader guards this automatically.
  • File sizes are in band. Spot-check that individual part-*.parquet files land in the 128–512 MB target, not clustered near a few hundred KB.
  • Row totals reconcile. The sum of rows across all partitions equals the source row count — a partition write that silently drops the small-cardinality date column would fail this.

A read that touches only a handful of fragments for a single-region query, with the CRS recovered from metadata, is the accepted end state — pruning is working and the geometry is self-describing.

Frequently Asked Questions

Should region or date be the first partition level?

Put the key that queries filter on most selectively first, which for a retail lake is usually region, because a market analyst asks about one market across many months far more often than about one month across every market. The order matters less for engines that read a catalog and more for those that list prefixes, but it always matters for housekeeping: a region-first layout makes retention and deletion for one market a directory operation instead of a scan.

What happens when a region is renamed or split?

Write the new partitions and leave the old ones in place until every consumer has moved. Renaming a partition value in an object store means copying data, and doing it in flight breaks any reader holding a path. A mapping table from old value to new, published with the dataset version, lets readers migrate on their own schedule — and it leaves an audit trail that explains why one quarter’s numbers are keyed differently from the next.

Is a spatial partition better than an administrative one?

Only when queries are genuinely spatial rather than organizational. A grid or hierarchical spatial index balances partition sizes beautifully and matches bounding-box queries, but almost every question a retail team asks arrives as a market name, not a bounding box, so an administrative key answers it with no translation. Where both patterns exist, the usual answer is an administrative partition plus a spatial column with row-group statistics, which prunes within the partition without a second layout.

← Back to Configuring AWS S3 for Geospatial Data Lakes