Writing GeoParquet Bounding-Box Metadata for Predicate Pushdown

A GeoParquet file with a bbox covering column lets a query engine skip entire row groups on a spatial filter, reading only the fraction of an archived object whose features fall inside the query envelope — without decoding a single WKB geometry. This guide is for data engineers who have already partitioned an archive and now need intra-file selectivity: writing a per-row bounding-box struct column, ensuring Parquet writes min/max statistics for its four members, and verifying that DuckDB spatial, GDAL, and pyarrow actually prune on those stats. The trap is that the bbox column and its row-group statistics are two different things — a writer can emit the covering column and still fail to populate the statistics that make pushdown work, leaving you with a correct file that scans in full. It operates under the Format Conversion & Pipeline Automation framework and refines the GeoParquet Migration Workflows reference with the covering-metadata layer that sits beneath directory-level partition pruning.

How Bbox Pruning Actually Works

Parquet stores each column in row groups, and for each column chunk it records min and max statistics in the file footer. A query engine reads the footer first, compares the filter to those statistics, and skips any row group whose range cannot satisfy the predicate. Geometry itself is an opaque WKB blob with no meaningful min/max, so it cannot be pruned. The GeoParquet bbox covering column solves this by adding a struct of four plain float columns — xmin, ymin, xmax, ymax — that the writer computes per feature. Because those four are ordinary numeric columns, Parquet writes real statistics for them, and a spatial filter becomes four numeric range comparisons the engine already knows how to push down.

Row-group skipping from bbox min/max statistics A query envelope is compared against the bbox min and max statistics of three row groups. Two row groups whose ranges do not intersect the envelope are skipped from the footer alone; the one overlapping row group is read and its geometries decoded. Query envelope x ∈ [4, 7] · y ∈ [2, 5] footer stats tested → Row group 0 xmin 0 · xmax 3 · ymin 0 · ymax 4 no overlap → SKIP Row group 1 xmin 4 · xmax 8 · ymin 1 · ymax 6 overlaps → READ & decode WKB Row group 2 xmin 12 · xmax 15 · ymin 8 · ymax 11 no overlap → SKIP

Pruning works only when three conditions hold together: the bbox column exists and is registered in the geo metadata as the covering for the geometry column, the four members carry populated min/max statistics, and the features are ordered so that a row group covers a compact region rather than the whole extent. Miss any one and the engine reads every row group.

Engine support differs in how the covering is consumed, so validate against the readers your archive actually serves. DuckDB’s spatial extension reads the covering field from the geo metadata and rewrites a ST_Intersects predicate on the geometry into range comparisons on the bbox members automatically, so a spatial filter prunes with no query rewrite from the caller. GDAL’s Parquet driver, from the version that implements GeoParquet 1.1, applies the same skipping when a spatial filter is set through -spat or the OGR API. Plain pyarrow does not understand spatial semantics, but because the bbox members are ordinary float columns you can push a numeric filter on bbox.xmin and friends through pq.read_table(..., filters=...) and it prunes on the identical statistics. All three lean on the same footer min/max, which is why the verification below inspects the statistics directly rather than trusting any one engine.

Computing and Writing the Bbox Covering Column

The bbox column is a Parquet struct with float fields xmin, ymin, xmax, ymax, computed in the geometry’s stored CRS. Compute it from the geometry bounds, then declare it in the geo metadata under the geometry column’s covering key so a reader knows which column covers which geometry. Write the members as float (not double) where precision allows — narrower columns produce tighter statistics pages and smaller footers.

import json
import geopandas
import pyarrow as pa
import pyarrow.parquet as pq

def write_with_bbox(src: str, dst: str, crs_epsg: int = 4326):
    gdf = geopandas.read_parquet(src)
    bounds = gdf.geometry.bounds  # DataFrame: minx, miny, maxx, maxy

    tbl = pa.Table.from_pandas(gdf.drop(columns="geometry"), preserve_index=False)
    # bbox as a struct column of four floats — the covering the engine prunes on.
    bbox = pa.StructArray.from_arrays(
        [pa.array(bounds.minx, pa.float32()), pa.array(bounds.miny, pa.float32()),
         pa.array(bounds.maxx, pa.float32()), pa.array(bounds.maxy, pa.float32())],
        names=["xmin", "ymin", "xmax", "ymax"])
    geom = pa.array(gdf.geometry.to_wkb())
    tbl = tbl.append_column("bbox", bbox).append_column("geometry", geom)

    geo_meta = {
        "version": "1.1.0", "primary_column": "geometry",
        "columns": {"geometry": {
            "encoding": "WKB",
            "geometry_types": sorted(gdf.geom_type.unique().tolist()),
            "crs": f"EPSG:{crs_epsg}",
            # Registers the bbox struct as the covering for this geometry column.
            "covering": {"bbox": {
                "xmin": ["bbox", "xmin"], "ymin": ["bbox", "ymin"],
                "xmax": ["bbox", "xmax"], "ymax": ["bbox", "ymax"]}}}},
    }
    tbl = tbl.replace_schema_metadata({b"geo": json.dumps(geo_meta).encode()})
    pq.write_table(tbl, dst, compression="zstd", compression_level=3,
                   row_group_size=50_000, write_statistics=True)

Two writer settings are load-bearing. write_statistics=True is the default in modern pyarrow, but pin it explicitly — a writer configured without it emits the covering column with no min/max, and pruning silently degrades to a full scan. row_group_size sets the granularity of pruning: smaller groups skip more precisely but enlarge the footer, and the sweet spot for archived spatial data is covered in the Row Group Sizing Strategies reference.

Ordering Features So Row Groups Stay Compact

Statistics only prune well when each row group covers a small, contiguous area. If features are in insertion order — say, chronological survey order that criss-crosses a continent — every row group’s bbox spans the whole extent, the min/max ranges all overlap the query, and nothing is skipped even though the statistics are present. Sort by a spatial key before writing so adjacent rows are spatially adjacent.

import h3
# Sort features along a spatial curve so each 50k-row group is spatially compact.
c = gdf.geometry.to_crs(4326).centroid
gdf["_sort"] = [h3.latlng_to_cell(p.y, p.x, 7) for p in c]  # fine H3 as a proxy curve
gdf = gdf.sort_values("_sort").drop(columns="_sort").reset_index(drop=True)

A finer H3 index or a Hilbert curve both work as the sort key; the goal is only that spatial neighbors become row neighbors. This is complementary to directory-level pruning: partitioning by H3 spatial index skips whole files, then bbox statistics skip row groups inside the files that survive.

Verifying the Statistics Are Populated

Do not trust that the covering column implies working pruning — inspect the footer directly. parquet-tools (or pyarrow’s metadata API) exposes per-row-group statistics for each bbox member.

python -c "
import pyarrow.parquet as pq
md = pq.ParquetFile('s3://spatial-archive/parcels/region_north.parquet').metadata
for rg in range(md.num_row_groups):
    for col in range(md.num_columns):
        c = md.row_group(rg).column(col)
        if c.path_in_schema.startswith('bbox.'):
            s = c.statistics
            print(f'rg{rg} {c.path_in_schema}: min={s.min} max={s.max} '
                  f'has_stats={s.has_min_max}')"

Expected output — every bbox member must report has_stats=True with a min/max that varies across row groups (proving spatial ordering worked):

rg0 bbox.xmin: min=-124.41 max=-122.09 has_stats=True
rg0 bbox.xmax: min=-124.38 max=-121.94 has_stats=True
rg1 bbox.xmin: min=-121.88 max=-119.02 has_stats=True   ← distinct range from rg0
rg1 bbox.xmax: min=-121.80 max=-118.91 has_stats=True

If ranges are identical across row groups, ordering failed and no pruning will occur. If has_stats=False, the writer dropped statistics. To confirm an engine acts on the stats, run EXPLAIN ANALYZE in DuckDB with a tight envelope filter on the bbox members and check that the reported rows-scanned count is a fraction of the file total. Validate the covering structure against the official GeoParquet Specification covering field definition.

Troubleshooting Bbox Pushdown

Symptom Cause Fix
Full file scanned despite a bbox filter min/max statistics absent from the footer Set write_statistics=True and re-write; verify with the pyarrow metadata dump
Statistics present but nothing skipped Features unsorted, so every row-group bbox spans the whole extent Sort by an H3 or Hilbert key before writing so row groups stay spatially compact
Engine ignores the bbox column entirely covering not declared in the geo metadata Add the covering.bbox member paths so readers map the struct to the geometry column
Pruning works in DuckDB but not GDAL Reader predates GeoParquet 1.1 covering support Confirm the GDAL/engine version implements the 1.1 covering spec; upgrade if older

Operational Execution Checklist

Part of the Spatial Data Archival knowledge base.