Partitioning GeoParquet for Athena Cost Control

For Athena to be cheap, partition GeoParquet on the columns your queries actually filter by — in practice a coarse spatial key (admin region or tile) plus a date — and serve those partitions with partition projection rather than registered Hive partitions once the count grows. Athena bills on bytes scanned, so every partition a query can skip is money saved. The spatial predicate never lowers the bill; only pruning does. This page sharpens the layout guidance from AWS Athena spatial queries on GeoParquet into a concrete key-selection and sizing decision, and it flags the small-file trap that quietly cancels out good partitioning.

Quick-Reference: Partition Key Choices

Partition scheme Prunes well when Watch-out Primary use case
Admin region (state, country, basin) Queries carry a region predicate Skewed regions create hot, oversized partitions Regional dashboards, jurisdiction reporting
Fixed spatial tile / geohash prefix Queries are arbitrary bounding boxes Dense areas over-split, sparse areas waste paths Map viewports, ad-hoc bbox analytics
Date (ingest or event) Queries are time-bounded Alone it does not prune spatially Time-series, incremental daily loads
Region + tile (two levels) Queries filter space two ways More paths; needs projection to stay fast High-volume interactive spatial queries

Choosing Partition Keys

The rule is unglamorous: partition on the columns that appear in your WHERE clause almost every time. A partition column only earns its keep if queries reference it, because pruning happens by matching the predicate against partition values before any file opens. A perfectly balanced tiling scheme that no query filters on prunes nothing and just adds path overhead.

Admin-region keys suit teams whose questions are framed jurisdictionally — parcels in one county, hydrology within one basin. They are cheap to reason about and align with how results are reported. Their weakness is skew: a metropolitan county may hold a hundred times the geometry of a rural one, so a single region partition becomes a hot, oversized path that dominates scan time. Spatial tiles fix skew by cutting space on a regular grid or a fixed-length geohash prefix, so an arbitrary bounding-box query prunes to just the tiles it overlaps. The trade is that dense urban tiles over-split into tiny files while sparse ocean tiles waste directory entries. The technique here is the same co-location idea behind spatial partitioning with quadtree indexes: put features that are queried together into the same physical path.

Date keys are almost always worth adding as a second dimension for datasets that grow over time, because incremental loads land in fresh date paths and time-bounded queries prune the rest. Date alone, though, prunes nothing spatially — pairing it with a region or tile key is what gives you two independent levers. Whichever keys you pick, prefer partition projection over registered Hive partitions once the count climbs: projection computes the target prefixes from the predicate, sidestepping both slow Glue listing and MSCK REPAIR TABLE.

Focused Implementation

The snippet writes a GeoParquet dataset partitioned by admin region and a geohash tile prefix, then registers it for Athena partition projection. It derives the tile key from geometry centroids so spatial locality is baked into the path layout.

python
# python>=3.10, geopandas>=0.14, pyarrow>=14.0, awswrangler>=3.5
from __future__ import annotations

import geopandas as gpd
import awswrangler as wr


def add_partition_keys(gdf: gpd.GeoDataFrame, precision: int = 3) -> gpd.GeoDataFrame:
    """Attach an admin region and a geohash tile prefix as partition columns.

    Args:
        gdf: Input features with a valid geometry column and a 'region' attribute.
        precision: Geohash length; 3 gives ~156 km cells, 4 ~39 km. Higher
                   precision prunes finer but risks small files in dense areas.
    """
    if "region" not in gdf.columns:
        raise KeyError("expected a 'region' attribute to use as the top partition")

    centroids = gdf.geometry.representative_point()
    gdf = gdf.copy()
    # Encode centroid to a geohash prefix; keeps spatially near rows in one path.
    gdf["tile"] = [
        _geohash(pt.y, pt.x, precision) for pt in centroids
    ]
    return gdf


def write_partitioned(gdf: gpd.GeoDataFrame, s3_path: str, database: str, table: str) -> None:
    """Write partitioned GeoParquet and enable Athena partition projection."""
    wr.s3.to_parquet(
        df=gdf.to_wkb(),                 # geometry -> WKB binary column
        path=s3_path,
        dataset=True,
        partition_cols=["region", "tile"],
        compression="zstd",
        max_rows_by_file=2_000_000,      # guards against tiny files
        database=database,
        table=table,
        athena_partition_projection=True,
    )

Note max_rows_by_file: it caps how many files each partition produces, the direct defence against the small-file trap discussed below. The to_wkb() call ensures Athena receives a binary geometry column it can pass to ST_GeomFromBinary.

Validation

After writing, run a representative bounding-box query and read the scanned bytes back. Pruning is working when the scan is proportional to the queried extent, not the table.

sql
-- Region + tile predicate must precede the spatial refine
SELECT count(*) FROM parcels
WHERE region = 'us-west'
  AND tile IN ('9q8', '9q9', '9qb')          -- tiles covering the bbox
  AND ST_Intersects(ST_GeomFromBinary(geometry),
                    ST_GeomFromText('POLYGON((...))'));

Then inspect DataScannedInBytes from GetQueryExecution. Expected ranges for a 1 TB dataset: an unpartitioned scan reads 900 GB or more; region-plus-tile pruning on a metro-scale bbox should read low tens of gigabytes or less. If the scan barely drops, your query is not carrying the partition predicate, or the tiles listed are far larger than the query extent.

Edge Cases and Caveats

The small-file trap. Fine tiling in dense areas can shatter a partition into hundreds of sub-megabyte files. Athena opens each file’s footer separately and pays per-object request overhead, so the pruning win is eaten by planning and request costs, and Parquet row-group pushdown weakens because tiny files hold tiny row groups. Cap files per partition (as max_rows_by_file does) and, if needed, compact with a CTAS pass. Aligning file size to your row group sizing strategy — 128 to 512 MB — keeps both pruning and pushdown healthy.

Partition skew from admin regions. A single dense region can hold most of the dataset, so a query touching it gains little from partitioning. Add a tile sub-level beneath the region, or switch the top key to tiles entirely, so no single path dominates.

Over-projection. Partition projection with an overly wide range (for example a date range starting years before any data exists) makes Athena consider prefixes that hold nothing. Empty prefixes are cheap but not free at scale; keep the projection bounds tight to the real data window.

Frequently Asked Questions

Should I partition GeoParquet by spatial tile or by admin region?

Partition by whichever column your queries filter on most. Admin region works when analysts think in states, countries, or basins and queries carry that predicate. Spatial tiles (a fixed grid or geohash prefix) work when queries are arbitrary bounding boxes, because a tile key prunes to the cells covering the box. Many teams use a coarse admin region as the top level and a tile prefix below it, giving two independent pruning dimensions.

How many partitions is too many for Athena?

With registered Hive partitions, planning slows noticeably past a few tens of thousands and Glue enforces hard per-table limits. With partition projection there is no registered-count limit because partitions are computed from the predicate, but you still pay if each partition holds only a few small files. The practical ceiling is set by file size, not partition count: aim for 128 to 512 MB of data per partition path.

Does partition pruning reduce the ST_ predicate cost?

Pruning reduces bytes scanned, which is what Athena bills; the ST_ predicate itself is always free because it runs in compute after decompression. So partitioning does not make ST_Intersects cheaper per row, but it dramatically reduces how many rows reach it. The whole point is to shrink the data the spatial function must run over, which lowers both cost and latency.


← Back to AWS Athena Spatial Queries on GeoParquet