Polars Prunes Parquet Row Groups With Chunk Statistics, Not the Page Index
How the streaming Parquet reader in Polars turns footer statistics into a DataFrame of min/max columns, evaluates the predicate on it, and drops whole row groups before any I/O.
TL;DR: Polars prunes Parquet row groups with the column chunk statistics that live in the file footer, not with the page index. Those statistics are decoded into a tiny DataFrame with
<col>_min,<col>_maxand<col>_nccolumns, the user’s predicate is evaluated against that DataFrame, and the row groups whose bit is clear are never fetched. Polars writes the page index (column index and offset index) when it writes a file with statistics enabled, but its own reader never reads it back: page-level skipping is not implemented.
Every reader of a Parquet file faces the same question: the predicate says
ts > 2026-01-01, which of the row groups in this file can be thrown away
without touching the bytes? The answer has to come from somewhere, and the
only metadata a reader is guaranteed to have is the footer.
Before diving in, one correction to the usual framing. Polars’ vendored Parquet implementation does write the page index — the column index and the offset index, the structures that hold per-page min/max and per-page byte offsets. If you write a Parquet file with Polars (with statistics enabled) and read it with DuckDB, Spark or DataFusion, those readers can skip pages inside a row group. Polars’ own reader does not do that. It skips whole row groups, using the statistics attached to each column chunk. The distinction matters: a row group is typically 100k+ rows, a page is typically a few thousand, so page-level skipping is a strictly finer-grained optimisation that Polars writes the data for but does not yet consume.
Which reader is the real one
The Parquet code lives in two crates. crates/polars-parquet holds the
deserializers, the statistics decoding, and a vendored copy of the parquet
crate itself (crates/polars-parquet/src/parquet/, with its own thrift decoder
and page reader). The plan — which row groups, which columns, in what order —
lives in the streaming engine, in
crates/polars-stream/src/nodes/io_sources/parquet/. That split matters: the
plan is where the pruning decision is made, and it is not in the crate you would
guess from the name.
The plan is built in init_morsel_distributor, in the prefetch task, before a
single byte of column data is read
(crates/polars-stream/src/nodes/io_sources/parquet/init.rs:197):
let has_row_index = row_index.is_some();
let row_group_mask = calculate_row_group_pred_pushdown_skip_mask(
row_group_slice.clone(),
use_statistics,
predicate.as_ref(),
&metadata,
projected_arrow_fields.clone(),
row_index,
verbose,
task_metrics.as_deref(),
runtime_range_skipper.as_deref(),
)
.await?;
let mut row_groups = row_groups_to_read(
&metadata,
row_group_slice,
row_group_mask.as_ref(),
row_offset,
slice_range,
);row_group_slice is the result of slice pushdown: LIMIT 10 over a file with
200 row groups narrows the slice to the one row group that contains row 10, so
the statistics machinery never even looks at the other 199. row_group_mask is
the result of statistics pushdown: a bitmap with one bit per row group, set
where the row group cannot match. row_groups_to_read turns the two into a
deque of row groups to fetch
(crates/polars-stream/src/nodes/io_sources/parquet/row_group_data_fetch.rs:65):
/// The row groups of `row_group_slice` that `row_group_mask` keeps, in file order. The
/// first starts at `row_offset` and `slice_range` is relative to it.
pub(super) fn row_groups_to_read(
metadata: &FileMetadata,
row_group_slice: Range<usize>,
row_group_mask: Option<&Bitmap>,
mut row_offset: usize,
mut slice_range: Option<Range<usize>>,
) -> VecDeque<RowGroupToRead> {
let mut row_groups = VecDeque::with_capacity(row_group_slice.len());
for (i, idx) in row_group_slice.enumerate() {
let num_rows = metadata.row_groups[idx].num_rows();
let slice = slice_range.as_mut().map(|slice_range| {
let rg_row_start = slice_range.start;
let rg_row_end = slice_range.end.min(num_rows);
*slice_range = slice_range.start.saturating_sub(num_rows)
..slice_range.end.saturating_sub(num_rows);
(rg_row_start, rg_row_end - rg_row_start)
});
if !row_group_mask.is_some_and(|mask| mask.get_bit(i)) {
row_groups.push_back(RowGroupToRead {
idx,
row_offset,
slice,
});
}
row_offset = row_offset.saturating_add(num_rows);
}
row_groups
}Note the mask semantics: a set bit means “skip”. The function keeps the row
groups whose bit is clear, and accumulates row_offset over the skipped ones
too, because the row offset of a row group is its position in the file, not
in the result set. That matters for with_row_index.
The columns in the plan
The other half of the plan is which columns to read, and it is a separate
mechanism from the row-group pruning. Projection pushdown has already decided
which Arrow fields the query needs; the reader then works out, per row group,
which byte ranges those fields live in, and fetches only those
(crates/polars-stream/src/nodes/io_sources/parquet/row_group_data_fetch.rs:406):
fn get_row_group_byte_ranges_for_projection<'a>(
row_group_metadata: &'a RowGroupMetadata,
columns: &'a mut dyn Iterator<Item = &PlSmallStr>,
) -> impl Iterator<Item = ParquetResult<std::ops::Range<usize>>> + 'a {
columns.flat_map(|col_name| {
row_group_metadata
.columns_under_root_iter(col_name)
// `Option::into_iter` so that we return an empty iterator for the
// `allow_missing_columns` case
.into_iter()
.flatten()
.map(|col| {
let byte_range = col.byte_range()?;
Ok(byte_range.start as usize..byte_range.end as usize)
})
})
}columns_under_root_iter is what makes nested schemas work: a projected
struct field is not one column but several leaf columns, and the reader has to
walk down to the leaves to find their byte ranges. Empty ranges — chunks
without pages — are dropped, because they need no I/O.
This is also where the “full projection” shortcut lives: if the query reads every column, the reader skips the per-column walk and just takes the row group’s whole byte range.
The statistics become a DataFrame
Here is the part I find elegant. Polars does not implement a separate
“evaluate predicate against min/max bounds” interpreter. Instead it turns the
per-row-group statistics into a small DataFrame — one row per row group, one
column per statistic — and then runs the user’s own predicate on that
DataFrame
(crates/polars-stream/src/nodes/io_sources/parquet/statistics.rs:184):
for projection in projected_arrow_fields.iter() {
let c = projection.output_name();
if !skip_batch_columns.contains(c) {
continue;
}
let mut statistics =
load_parquet_column_statistics(&metadata, row_group_slice.clone(), projection)?;
// Note: Order is important here. We re-use the transform for the output column, meaning
// that it may set the column name.
statistics.min = projection.apply_transform(statistics.min)?;
statistics.max = projection.apply_transform(statistics.max)?;
// The null counts of struct fields have the file's field layout. The transform may
// reorder, rename or insert fields (e.g. by field ID), and casts the leaves, so it
// does not apply to them: use unknown null counts.
if let ArrowFieldProjection::Mapped { output_dtype, .. } = projection
&& matches!(projection.arrow_field().dtype(), ArrowDataType::Struct(_))
{
statistics.null_count = Column::full_null(
PlSmallStr::EMPTY,
statistics.null_count.len(),
&null_count_dtype(output_dtype),
);
}
let statistics = statistics.with_base_column_name(c);
columns.extend([statistics.min, statistics.max, statistics.null_count]);
}
if let Some(row_index) = row_index {
let statistics = build_row_index_statistics(&row_index, row_groups_slice)
.with_base_column_name(&row_index.name);
columns.extend([statistics.min, statistics.max, statistics.null_count]);
}
let statistics_df = DataFrame::new(num_row_groups, columns)?;
sbp.evaluate_with_stat_df(&statistics_df)So a filter like pl.col("ts") > cutoff becomes, internally, a filter over a
DataFrame with columns ts_min, ts_max and ts_nc. The predicate compiler
already knows how to rewrite ts > cutoff into ts_min > cutoff; the skip
batch predicate is that rewrite, evaluated over statistics. One code path for
“filter a frame” and “filter a set of row groups”, and no second expression
language to maintain.
Two details are worth pulling out. The first is the len column: the number of
rows in each row group is pushed into the statistics frame as a plain column,
so a predicate like pl.len() > 1000 prunes row groups too, without any
special case
(crates/polars-stream/src/nodes/io_sources/parquet/statistics.rs:167):
let mut columns = Vec::with_capacity(1 + skip_batch_columns.len() * 3);
let lengths: Vec<IdxSize> = row_groups_slice
.iter()
.map(|rg| rg.num_rows() as IdxSize)
.collect();
columns.push(Column::new("len".into(), lengths));The second is that the statistics are pushed through the projection’s
transform before the predicate sees them. A projection can rename a column,
reorder struct fields, or cast a leaf (projection.apply_transform), and the
predicate was compiled against the transformed column. Applying the transform
to the statistics as well is what keeps the two in agreement.
The columns are named <col>_min, <col>_max and <col>_nc
(crates/polars-io/src/predicates.rs:363):
columns.extend([
Column::new_scalar(format_pl_smallstr!("{col}_min"), min, 1),
Column::new_scalar(format_pl_smallstr!("{col}_max"), max, 1),
Column::new_scalar(format_pl_smallstr!("{col}_nc"), nc, 1),
]);The result is a bitmap, one bit per row group, and the verbose log prints the
savings
(crates/polars-stream/src/nodes/io_sources/parquet/statistics.rs:130):
if verbose && (mask.is_some() || runtime_range_skipper.is_some()) {
let num_row_groups = row_group_slice.len();
eprintln!(
"[ParquetFileReader]: Predicate pushdown: \
reading {} / {} row groups",
mask.as_ref().map_or(num_row_groups, |m| m.unset_bits()),
num_row_groups,
);
}Where the numbers come from
The footer is kept in memory as a buffer, and the statistics are stored as byte
ranges into it. Resolving a bound is therefore a slice, not a copy
(crates/polars-parquet/src/parquet/metadata/column_chunk_metadata.rs:111):
/// The plain-encoded min and max of the chunk, borrowed from `footer_buf`;
/// `None` when the chunk has no statistics.
pub fn raw_bounds<'a>(&self, footer_buf: &'a [u8]) -> Option<RawBounds<'a>> {
let stats = self.column_chunk.meta_data.as_ref()?.statistics.as_ref()?;
Some(RawBounds {
min: stats.min_value.map(|range| range.resolve(footer_buf)),
max: stats.max_value.map(|range| range.resolve(footer_buf)),
min_is_exact: stats.is_min_value_exact != Some(false),
max_is_exact: stats.is_max_value_exact != Some(false),
})
}The is_exact flags are a nice touch: a writer may record a truncated or
approximate bound, and an inexact bound is worthless for pruning (it may
exclude rows that do match). Polars validates the bounds and then throws away
the inexact ones rather than pruning on them.
Before a bound can be compared, the file’s declared sort order has to match the
order Polars compares with — an unsigned column whose statistics were collected
as signed cannot be used
(crates/polars-parquet/src/arrow/read/statistics/bounds.rs:151):
/// Whether the `min_value` and `max_value` of a leaf bound the values polars decodes
/// as `dtype`: the file declares an order for the leaf, and it is the one polars
/// compares the decoded values with.
fn bounds_are_usable(
column_order: ColumnOrder,
physical_type: &PhysicalType,
dtype: &ArrowDataType,
) -> bool {
let Some(required) = required_sort_order(dtype, physical_type) else {
return false;
};
match column_order {
ColumnOrder::TypeDefinedOrder(order) => order == required,
// Total order agrees with the float comparison on every non-NaN value.
ColumnOrder::IEEE754TotalOrder => {
matches!(physical_type, PhysicalType::Float | PhysicalType::Double)
},
ColumnOrder::Unsupported | ColumnOrder::Undefined => false,
}
}The same care shows up for timestamps: if the reader multiplies a
microsecond timestamp into nanoseconds and the multiplication overflows, the
values have no order, so both bounds are required and both must be in range,
otherwise the chunk bounds nothing
(crates/polars-parquet/src/arrow/read/statistics/bounds.rs:264).
Nested columns need one more step. A struct column is not one Parquet leaf but
several, and a predicate on a single struct field should be able to prune on
that field’s statistics. load_struct_column_statistics therefore assembles a
per-field min/max/null-count structure from the leaves, and gives up — falls
back to all-null statistics, which prune nothing — if the leaf count and the
struct field count do not line up
(crates/polars-stream/src/nodes/io_sources/parquet/statistics.rs:732).
The “already applied” flag
There is a second level of pruning that happens before the reader ever sees the
file: table statistics. If a Parquet dataset has per-file min/max statistics
recorded out of band, a predicate can be evaluated against them and whole
files skipped. When that happens, the reader must not evaluate the predicate
again — or rather, the plan has to know whether there is anything left to
evaluate. That is what PredicateFileSkip is for
(crates/polars-descriptions/src/lib.rs:21):
pub struct PredicateFileSkip {
/// If `true` the predicate can be skipped at runtime.
pub no_residual_predicate: bool,
/// Number of files before skipping
pub original_len: usize,
}It is set when the file-level statistics are applied
(crates/polars-mem-engine/src/scan_predicate/functions.rs:563):
let predicate_to_readers = (!can_skip_scan_predicate).then_some(&scan_predicate);
if let Some(skip_files_mask) = skip_files_mask {
assert_eq!(skip_files_mask.len(), sources.len());
let predicate_file_skip = PredicateFileSkip {
no_residual_predicate: predicate_to_readers.is_none(),
original_len: sources.len(),
};and honoured when the pipeline is initialised: with no_residual_predicate
set, the predicate is simply not handed to the reader
(crates/polars-stream/src/nodes/io_sources/multi_scan/pipeline/initialization.rs:124):
Some(PredicateFileSkip {
no_residual_predicate: false,
original_len: _,
}) => (None, config.predicate.as_ref()),
Some(PredicateFileSkip {
no_residual_predicate: true,
original_len: _,
}) => (None, None),The one subtlety is runtime ranges: a predicate with a runtime range is not
consumed by the file-level statistics, because the range can change while the
scan runs. That is why can_skip_scan_predicate is
predicate.runtime_ranges.is_empty() in the hive-partition branch
(crates/polars-mem-engine/src/scan_predicate/functions.rs:407).
Ranges that arrive late
The runtime range is the case where a join discovers a filter after the scan
has started. The plan cannot prune on it, but the reader can still check each
row group against it just before fetching it. RuntimeRangeSkipper resolves
the range against the file’s statistics once
(crates/polars-stream/src/nodes/io_sources/parquet/statistics.rs:540):
fn skips_with(&self, checks: &[RangeCheck], row_group_idx: usize) -> bool {
checks.iter().any(|check| match check {
RangeCheck::KeepAll => false,
RangeCheck::SkipAll => true,
RangeCheck::Bounds {
leaf,
lo,
hi,
nulls_match,
} => {
let leaf = self.hints[*leaf].leaf.as_ref().unwrap();
let Some((min, max)) = leaf.row_group_bounds(row_group_idx, &self.metadata) else {
return false;
};
let below = lo
.as_ref()
.is_some_and(|lo| max.as_ref().is_some_and(|max| max < lo));
let above = hi
.as_ref()
.is_some_and(|hi| min.as_ref().is_some_and(|min| min > hi));
(below || above)
&& (!nulls_match
|| has_no_nulls(&self.metadata.row_groups[row_group_idx], leaf.leaf_idx))
},
})
}and the fetcher re-checks the front of the queue before each fetch, so a range
that tightened since the plan was made still skips work
(crates/polars-stream/src/nodes/io_sources/parquet/row_group_data_fetch.rs:139):
/// Moves past the row groups at the front that the runtime ranges skip.
fn skip_front(&mut self) {
while let Some(row_group) = self.row_groups.front()
&& self
.runtime_range_skipper
.as_ref()
.is_some_and(|s| s.skips(row_group.idx))
{
self.row_groups.pop_front();
}
}The page index: written, not read
The write side is where the page index shows up. When the footer is written,
Polars serialises a column index and an offset index for every column chunk
— the column index only if the writer was configured to collect statistics,
the offset index unconditionally
(crates/polars-parquet/src/parquet/write/file.rs:205):
if self.options.write_statistics {
// write column indexes (require page statistics)
self.row_groups
.iter_mut()
.zip(self.page_specs.iter())
.try_for_each(|(group, pages)| {
group.columns.iter_mut().zip(pages.iter()).try_for_each(
|(column, pages)| {
let offset = self.offset;
if let Some(length) = write_column_index(&mut self.writer, pages)? {
self.offset += length;
column.column_index_offset = Some(offset as i64);
column.column_index_length = Some(length as i32);
}
ParquetResult::Ok(())
},
)?;
ParquetResult::Ok(())
})?;
};and the offset index right after it, unconditionally. So a file written by Polars carries per-page min/max and per-page offsets, and any reader that implements the page index can use them.
Polars’ own reader does not. The vendored page reader still carries the
comment from the crate it was forked from
(crates/polars-parquet/src/parquet/read/page/reader.rs:68):
/// A fallible [`Iterator`] of [`CompressedDataPage`]. This iterator reads pages back
/// to back until all pages have been consumed.
///
/// The pages from this iterator always have [`None`] [`crate::parquet::page::CompressedDataPage::selected_rows()`] since
/// filter pushdown is not supported without a
/// pre-computed [page index](https://github.com/apache/parquet-format/blob/master/PageIndex.md).
pub struct PageReader {and the read path never opens the column index or the offset index: the only references to them in the reader are the metadata accessors that tell you where the index is, not what is in it.
The bottom line
- Pruning is per row group, using the per-column-chunk min/max/null-count in the footer. The bounds are borrowed from the footer buffer, not copied, and are dropped if the file’s sort order or an exactness flag makes them unusable.
- The predicate is not re-implemented for statistics. It is evaluated on a
DataFrame of
<col>_min/<col>_max/<col>_nccolumns, which means the filter you wrote and the filter that prunes row groups are the same expression. PredicateFileSkip::no_residual_predicateis the “already applied” flag: it tells the reader that table statistics already consumed the predicate, so the reader must not evaluate it again.- A range published at run time is checked again per row group, immediately before the fetch.
- Polars writes the page index (with statistics enabled) so that other engines can prune at page granularity. If you want that from Polars itself, it is not there yet, and the win would be largest on files with many small row groups and highly selective predicates.