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.

Why min/max over WKB is useless and min/max over four doubles is an index The upper half shows a row group's footer entry for a WKB geometry column: the minimum and maximum are lexicographic byte comparisons of binary blobs, which carry no spatial meaning, so the engine cannot rule the group out and must read it. The lower half shows the same row group with a covering bbox struct: four double columns each carry a numeric minimum and maximum, which together describe the group's spatial extent, so the engine can compare that rectangle against the query window and skip the group without reading any data. Footer statistics for the geometry column alone row group 7 · column "geometry" (BYTE_ARRAY) min = 0x0103000000C4… max = 0x0106000000A1… a lexicographic byte comparison — no spatial meaning engine verdict: cannot rule it out reads the whole row group and decodes every geometry to find out it wanted none of them Footer statistics with a covering bbox struct row group 7 · struct "bbox" (4 × DOUBLE) xmin ∈ [−2.51, −2.44] · ymin ∈ [51.40, 51.46] xmax ∈ [−2.50, −2.42] · ymax ∈ [51.41, 51.48] engine verdict: no overlap → skip decided from the footer, before any read; zero bytes of geometry transferred

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

python
# 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.

sql
-- 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.

The same covering columns on unsorted and sorted files Two maps of the same dataset with a query window drawn on each. In the unsorted file, four row groups each have bounding boxes spanning almost the entire map, so all four intersect the window and none can be skipped even though the covering columns are present and correct. In the Hilbert-sorted file the four row groups occupy compact separate regions, only one intersects the window, and three are skipped from the footer. The covering columns are identical in both cases; only the row order differs. Insertion order — 0 of 4 skipped RG0 RG1 RG2 RG3 query every extent covers the window covering columns present and useless Hilbert-sorted — 3 of 4 skipped RG0 RG1 RG2 RG3 query skip skip skip one extent overlaps — one group read same columns, different row order The one geometry that defeats a covering column A feature spanning the antimeridian is shown with its computed bounding box. Because the box is derived from minimum and maximum longitude, a feature with vertices at both plus and minus one hundred and seventy-nine degrees produces a box spanning the entire globe. Any row group containing it can never be skipped by any query, so the whole group becomes permanently unprunable. One feature, one globe-spanning bounding box longitude 0 -180 +180 computed bbox: xmin -179, xmax +179 The feature occupies two small areas; its bounding box occupies the planet. Split at the antimeridian before writing, and assert that no xmin exceeds its xmax.

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.


← Back to Understanding Parquet Columnar Storage for GIS