Parquet Page Index and Bloom Filters for Spatial Queries

Row-group statistics get a reader down to about 128 MB of candidate data; the page index gets it down to about a megabyte, and a bloom filter answers point lookups without reading data at all. Both structures are cheap to write, both live in the footer where a reader can fetch them in one request, and both are ignored by default in enough engines that they are worth verifying rather than assuming. This page continues understanding Parquet columnar storage for GIS, one level finer than bbox covering columns.

Quick Reference

Structure Answers Granularity Primary Use Case
Row-group statistics “could this group match?” ~128 MB Coarse spatial window pruning
Page index “could this page match?” ~1 MB Highly selective windows and range filters
Bloom filter “is this value definitely absent?” Column chunk Equality lookups on identifiers
Dictionary page “what values exist here?” Column chunk Low-cardinality categorical filters

Three Levels of “Do Not Read This”

Parquet’s skipping story is a hierarchy, and each level narrows what the level above admitted.

At the top, row-group statistics in the footer give min and max per column per row group. With covering bbox columns present these become a spatial filter, and they remove most of the file for a windowed query. What they cannot do is help inside a surviving group: once the engine decides to read a 128 MB row group, it reads all of it for the columns it needs.

The page index closes that gap. A column chunk is physically a sequence of pages, each typically around a megabyte, and the page index records min, max, and null count for every one of them — stored in the footer, not inline. That location is the entire point: a reader fetches the index in one request and then issues reads only for the pages whose statistics survive the predicate. Without the separation it would have to read pages to discover whether to read them.

Bloom filters answer a different question. They are compact probabilistic structures that can say definitely not present for an exact value, with a small false-positive rate and no false negatives. For a query like WHERE uprn = '100023336956' across a dataset partitioned by region, a bloom filter per column chunk eliminates almost every chunk without touching the data, turning a full scan into a handful of reads.

How each skipping structure narrows what the previous one admitted A funnel of four stages for one selective query against a forty gigabyte file. Reading the footer costs a few kilobytes and identifies eleven candidate row groups out of three hundred and twenty. Row-group bbox statistics reduce that to four groups, about five hundred megabytes. The page index then reduces those four groups to nine pages, about eleven megabytes. Finally the exact spatial predicate is evaluated on the decoded rows of those pages, returning four hundred and twelve features. Each stage is decided from footer metadata alone until the final one. One selective window query against a 40 GB GeoParquet file whole file — 320 row groups, 40 GB read the footer: a few kilobytes after row-group bbox statistics — 4 groups, ~500 MB decided entirely from the footer, no data read after the page index — 9 pages, ~11 MB still footer-only: the index lives beside the statistics exact predicate — 412 features the only stage that decodes geometry Without the page index the query reads 500 MB instead of 11 MB — a 45× difference decided by one writer flag. The narrower the query, the larger the gap: broad queries barely notice, selective ones live or die on it.

Writing Them

python
# Requires: pyarrow>=16, geopandas>=1.0  (Python 3.10+)
from __future__ import annotations

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


def write_with_fine_grained_index(
    table: pa.Table,
    target: str,
    *,
    bloom_columns: tuple[str, ...] = (),
    row_group_rows: int = 200_000,
    page_size_bytes: int = 1 << 20,
) -> dict[str, object]:
    """Write GeoParquet with page-level statistics and targeted bloom filters."""
    missing = [c for c in bloom_columns if c not in table.column_names]
    if missing:
        raise KeyError(f"bloom filter requested for absent columns: {missing}")

    for column in bloom_columns:
        # A bloom filter over a low-cardinality column is wasted space: the
        # dictionary page already answers the question more precisely.
        distinct = pa.compute.count_distinct(table[column]).as_py()
        if distinct < 1000:
            raise ValueError(
                f"{column} has only {distinct} distinct values — use the "
                f"dictionary page rather than a bloom filter"
            )

    pq.write_table(
        table, target,
        row_group_size=row_group_rows,
        data_page_size=page_size_bytes,
        compression="zstd",
        compression_level=3,
        write_statistics=True,
        # The page index is the footer-resident per-page min/max structure.
        write_page_index=True,
        write_bloom_filter=list(bloom_columns) if bloom_columns else False,
    )

    meta = pq.read_metadata(target)
    return {
        "row_groups": meta.num_row_groups,
        "rows": meta.num_rows,
        "page_index": bool(meta.row_group(0).column(0).offset_index_offset),
        "bloom_columns": list(bloom_columns),
    }

Validation

The check is empirical: run a selective query with the structures present and confirm bytes read collapses. Metadata presence alone proves nothing, because the reader may ignore it.

python
# Requires: pyarrow>=16 — confirm the page index is present and populated
import pyarrow.parquet as pq

meta = pq.read_metadata("parcels.parquet")
group = meta.row_group(0)
column = group.column(0)
assert column.offset_index_offset, "no page index was written"
assert column.column_index_offset, "no page-level statistics were written"
print(f"{meta.num_row_groups} row groups, page index present, "
      f"{group.total_byte_size / 1e6:.0f} MB per group")
sql
-- DuckDB: the same selective query, measured. Compare against a file written
-- without write_page_index to see whether the reader is using it.
SET enable_profiling = 'json';
SELECT count(*) FROM read_parquet('parcels.parquet')
WHERE bbox.xmin <= -2.44 AND bbox.xmax >= -2.46
  AND bbox.ymin <= 51.46 AND bbox.ymax >= 51.44;

Healthy result: bytes read for a very selective window should be single-digit megabytes on a multi-gigabyte file. If it lands in the hundreds of megabytes, the reader is pruning row groups but not pages — either the index was not written or the engine is not consulting it.

An identifier lookup with and without bloom filters A dataset of eight column chunks is queried for one identifier value. Without bloom filters the engine cannot rule any chunk out from an equality predicate on a high-cardinality column, so it reads and decodes all eight. With a bloom filter per chunk it tests the value against each filter, gets definitely-absent for seven of them, and reads only the eighth — plus one false positive that costs a wasted read but never a wrong answer, because bloom filters have no false negatives. Lookup: WHERE uprn = '100023336956' Without bloom filters — all eight chunks read 8 chunks decoded · 3.1 GB read to return one row With bloom filters — two chunks read absent absent false pos. absent absent match absent absent 2 chunks decoded · 780 MB read — the false positive costs time, never correctness Bloom filters have no false negatives, so a "definitely absent" verdict is always safe to act on. Why the page index has to live in the footer Two layouts. With statistics only in each page header, a reader must fetch a page to learn whether it wanted that page, which defeats the purpose entirely. With the page index gathered in the footer, one request retrieves every page statistic in the file, and the reader then issues reads only for pages that survive the predicate. The separation is the whole mechanism. Statistics inline with each page to learn whether page 7 matches, the reader must fetch page 7 — the check costs what it saves Page index gathered in the footer page index — one fetch one request retrieves every page statistic; only the surviving pages are then read

Edge Cases and Caveats

Page size set too large. A 32 MB page is a second row group with none of the benefits: the index gets coarse and the skipping stops paying. Keep pages around a megabyte so a page is a genuinely small unit of work, and let row group size carry the coarse-grained trade described in row group sizing strategies.

Bloom filters on low-cardinality columns. A filter over a column with forty distinct values is strictly worse than the dictionary page that already exists for it — it costs space and answers less precisely. Reserve bloom filters for identifiers and other high-cardinality lookup keys, which is why the implementation above refuses to write one below a thousand distinct values.

Page-level statistics on unsorted columns. Like row-group statistics, page statistics only skip when values within a page are clustered. On an unsorted column every page spans the full value range and the index is dead weight. The sort that helps here is the same space-filling-curve ordering that makes bbox statistics useful.

Frequently Asked Questions

What does the Parquet page index add over row-group statistics?

Resolution. Row-group statistics let an engine skip a whole row group, which is typically 128 MB; page-level statistics let it skip individual pages inside a group, which are typically a megabyte or less. The important structural detail is that the page index lives in the footer rather than inline with the pages, so a reader fetches all the page statistics in one request and then reads only the pages that survive — without that separation it would have to read the pages to learn whether to read them.

Are bloom filters useful for spatial data?

Not for geometry, but very much for the identifier columns beside it. A bloom filter answers ‘is this value definitely absent?’ for equality predicates, which is exactly the shape of a feature-by-identifier lookup across a partitioned dataset. It cannot help a range or intersection predicate, so it complements bbox covering columns rather than competing with them: the covering handles where, the bloom filter handles which.

Why does my engine ignore the page index I wrote?

Support is uneven and frequently opt-in. Some readers only consult the page index when a specific flag is set, some use it for numeric columns but not nested struct fields, and some ignore bloom filters entirely. Writing them costs little and is harmless when unused, but do not assume a benefit you have not measured — check bytes read for a selective query with and without the structures present.


← Back to Understanding Parquet Columnar Storage for GIS