GeoParquet bbox Covering Columns Explained
A covering bbox column is a struct of four ordinary float columns — xmin, ymin, xmax, ymax — that exists so a query engine can decide whether to read a row group without decoding any geometry. Parquet already records min and max statistics for every column in every row group; making the bounding box a set of numeric columns is what turns that generic mechanism into a spatial index. This page sits under understanding Parquet columnar storage for GIS.
Quick Reference
| Element | Value | Why | Primary Use Case |
|---|---|---|---|
| Column shape | struct<xmin,ymin,xmax,ymax: double> |
Parquet keeps stats per leaf column | Every GeoParquet written for query |
| Metadata | covering.bbox in the geo key |
Engines detect the struct by declaration | Cross-engine portability |
| Prerequisite | Rows sorted on a space-filling curve | Skipping needs compact per-group extents | Datasets over a few hundred MB |
| Predicate form | Compare the four fields directly | Lets the engine push down to the footer | Windowed reads on any engine |
| Cost | ~2–4% of file size | Four doubles per row, highly compressible | Almost always worth paying |
Statistics That Mean Something
Parquet stores, in its footer, the minimum and maximum value of every column within every row group. That is the entire skipping mechanism: an engine compares a predicate against those statistics and discards groups that cannot possibly match, before issuing a single read for the data itself.
For a geometry column this mechanism is inert. Geometry is stored as WKB — a binary blob — and the min and max of a set of blobs is a lexicographic comparison of bytes that begin with a type code and an endianness flag. Two polygons on opposite sides of the country may sort adjacently; two neighbours may not. The statistic exists and carries no spatial information whatsoever.
The covering column fixes this by putting the information into a form the generic mechanism already understands. Write xmin as a double column, and Parquet records the smallest and largest xmin in each row group. Do that for all four ordinates and the footer now contains, for free, the bounding box of every row group.
There is a subtlety worth stating plainly: the covering column is not itself an index. It is data that makes an existing generic mechanism spatially useful, and its effectiveness depends entirely on how the rows were ordered when the file was written. That is why the sorting step is not optional.
Writing Them
# Requires: geopandas>=1.0, pyarrow>=16, shapely>=2.0 (Python 3.10+)
from __future__ import annotations
import json
import geopandas as gpd
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
from shapely import bounds
def hilbert_sort(frame: gpd.GeoDataFrame, *, order: int = 16) -> gpd.GeoDataFrame:
"""Order rows so that spatially close features become physically close.
Without this the covering columns describe row groups that each span the
whole dataset, and no query can skip anything.
"""
if frame.crs is None:
raise ValueError("a CRS is required before spatial sorting")
return frame.iloc[frame.geometry.hilbert_distance(level=order).argsort()].reset_index(drop=True)
def write_with_covering(
frame: gpd.GeoDataFrame,
target: str,
*,
row_group_rows: int = 200_000,
compression: str = "zstd",
compression_level: int = 3,
) -> dict[str, int]:
"""Write GeoParquet with a declared bbox covering struct."""
if frame.empty:
raise ValueError("nothing to write")
ordered = hilbert_sort(frame)
extents = bounds(ordered.geometry.to_numpy()) # (n, 4): xmin ymin xmax ymax
if np.isnan(extents).any():
raise ValueError("null or empty geometries cannot be covered — filter them first")
table = pa.Table.from_pandas(ordered.drop(columns="geometry"), preserve_index=False)
table = table.append_column(
"geometry", pa.array(ordered.geometry.to_wkb(), type=pa.binary())
)
bbox_struct = pa.StructArray.from_arrays(
[pa.array(extents[:, i], type=pa.float64()) for i in range(4)],
names=["xmin", "ymin", "xmax", "ymax"],
)
table = table.append_column("bbox", bbox_struct)
# Declaring the covering is what makes engines treat the struct as the
# geometry's bounding box rather than as four unrelated numeric columns.
geo_meta = {
"version": "1.1.0",
"primary_column": "geometry",
"columns": {
"geometry": {
"encoding": "WKB",
"crs": json.loads(ordered.crs.to_json()),
"geometry_types": sorted({g.geom_type for g in ordered.geometry}),
"covering": {
"bbox": {
"xmin": ["bbox", "xmin"], "ymin": ["bbox", "ymin"],
"xmax": ["bbox", "xmax"], "ymax": ["bbox", "ymax"],
}
},
}
},
}
table = table.replace_schema_metadata({b"geo": json.dumps(geo_meta).encode("utf-8")})
pq.write_table(
table, target,
row_group_size=row_group_rows,
compression=compression,
compression_level=compression_level,
# Page-level statistics let engines narrow further inside a row group.
write_statistics=True,
write_page_index=True,
)
return {"rows": len(ordered), "row_groups": (len(ordered) // row_group_rows) + 1}
Validation
The number that matters is row groups read versus row groups present. Anything close to “all of them” means the sort did not happen or the predicate is not reaching the footer.
-- DuckDB: confirm the predicate prunes row groups rather than filtering after the read
EXPLAIN ANALYZE
SELECT count(*) FROM read_parquet('parcels.parquet')
WHERE bbox.xmin <= 2.42 AND bbox.xmax >= 2.28
AND bbox.ymin <= 48.90 AND bbox.ymax >= 48.82;
-- Look for: "Filters: bbox.xmin<=... " on the PARQUET_SCAN node, and a
-- rows-scanned figure far below the file's total row count.
Healthy ranges for a city-scale window on a national layer: fewer than 5% of row groups read, bytes scanned two orders of magnitude below file size, and a scan-to-return ratio under about 5:1. If bytes scanned approaches file size, check the sort first and the metadata declaration second.
Edge Cases and Caveats
Antimeridian-crossing geometries. A feature spanning ±180° gets a bounding box covering the entire globe in longitude, which makes its row group unskippable for every query. The convention is to split such features at the antimeridian before writing, and to assert that no xmin exceeds its xmax in the covering columns as a cheap detector.
Row groups sized without reference to skipping. Covering columns give per-row-group resolution, so a file with two enormous row groups can skip almost nothing regardless of sort quality. Size row groups so that a typical query window intersects a small number of them — the trade explored in row group sizing strategies.
Predicates written only as spatial functions. ST_Intersects(geom, envelope) is correct but is not always pushed into the footer, depending on engine and version. Writing the bbox comparison explicitly alongside the spatial predicate costs nothing, is always pushed down, and leaves the exact predicate to do the refinement — the pattern used throughout DuckDB spatial queries on S3.
Frequently Asked Questions
Why does a query engine need a bbox column when the geometry is already there?
Because the geometry column holds opaque WKB blobs, and Parquet’s per-row-group statistics over a binary column are min and max of the raw bytes, which mean nothing spatially. To decide whether a row group can be skipped, the engine needs numbers it can compare. Four plain float columns give it exactly that: Parquet records min and max for each of them per row group, so the engine can rule out a group from the footer alone, without reading or decoding a single geometry.
Do covering columns help if the file is not spatially sorted?
Barely. Skipping works when a row group’s bounding box is small relative to the dataset, and that only happens when spatially close features are stored close together. In insertion order, a row group of a national dataset typically spans the whole country, so every group intersects every query window and nothing can be skipped. Sorting on a Hilbert or Z-order curve before writing is what gives the covering columns something to say.
How much space do bbox covering columns cost?
Four float64 values per row, which is 32 bytes uncompressed and rather less after compression because neighbouring rows in a spatially sorted file have very similar bounds. On a typical polygon layer it adds around 2 to 4 per cent to file size and routinely removes 90 per cent or more of the bytes a windowed query has to read, which is one of the better trades available in the format.
Related
- Understanding Parquet Columnar Storage for GIS — parent guide: row groups, column chunks, and pages
- Parquet Page Index and Bloom Filters for Spatial Queries — the finer-grained structures that narrow within a row group
- Row Group Sizing Strategies for Parquet — choosing the granularity these statistics describe
- Space-Filling Curves for Spatial Partitioning — the sort that makes the extents compact