Page Index — Skipping Inside a Chunk
Run actual page-selective scans across eight writer layouts →
Row-group statistics are coarse: a row group is usually hundreds of thousands of rows. Since
parquet-format 2.5 (2018) writers can add a page index, two small structures per
column chunk that sit between the last row group and the footer:
- ColumnIndex — min, max and null count for every page, plus whether the pages are sorted.
- OffsetIndex — for every page, its byte offset, size and the row it starts at.
Together they let an engine pick out only the pages that can match, and fetch exactly those byte
ranges. Each ColumnChunk in the footer records where its two index structures are, so
the reader picks them up alongside the footer and before any data.
Column: Row group:
WHERE flipper_length_mm 200
2 of 3 pages skipped:
566 of 767 chunk bytes never fetched.
| page | first row | offset | bytes | min | max | nulls |
|---|
| 0 | 0 | 127 | 283 | 172 | 189 | 0 |
| 1 | 64 | 410 | 283 | 189 | 196 | 0 |
| 2 | 128 | 693 | 201 | 178 | 210 | 1 |
ColumnIndex at 1,853 (57 B) ·
OffsetIndex at 2,075 (32 B) ·
boundary_order UNORDERED
page ranges overlap, so each page has to be checked on its own.
Row group 0's flipper pages are unordered because the last page starts over with the
Chinstrap penguins; row group 1's happen to be ascending. Pick species to see the
same structures for a string column: the min and max are strings, and the third page of row group
0 is the only one whose range spans two species.
On a real file with millions of rows per row group and thousands of pages, this is what turns a
selective query from "read the row group" into "read three pages". Engines that support it
(Spark, Trino, DuckDB, arrow) read the index once, plan the byte ranges, and issue one request per
run of pages.