Polars Pushes Predicates Into the Parquet Scanner

How a Polars filter expression is rewritten into a min/max row-group predicate that the Parquet reader evaluates against the footer statistics to skip whole row groups.

TL;DR: A Polars filter does not travel to the Parquet reader as a special “skip predicate”. It is the same expression tree, compiled a second time into a predicate over a synthetic frame whose columns are named col_min, col_max and col_nc — one row per row group. The reader evaluates that predicate over the statistics it read from the Parquet footer, and every row group the predicate rejects is never fetched, decompressed or decoded. The optimizer hands the filter to the scan by deleting the IR::Filter node and putting the predicate on the IR::Scan, and a flag on the scan says whether the predicate still has to filter rows or whether it has already been settled at the file level.

Reading a Parquet file and filtering it is the single most common thing a dataframe engine does, and the naive version of it is silly: decode every column of every row group, then throw away 99% of the rows. Parquet’s own format makes that avoidable. A row group is the unit of compression and encoding, and the file footer carries per-row-group statistics — a minimum, a maximum and a null count per column. If those statistics prove that a row group cannot contain a matching row, the row group never has to be read at all.

The interesting part is not that Polars uses the statistics. It is how the predicate gets from the logical plan into that decision, because the two sides of the problem want completely different shapes of expression. The whole path looks like this:

text
   logical plan                IR                    reader
  ---------------        ---------------        -------------------
   IR::Filter          ->  IR::Scan              ->  row group 0  (skip)
     (predicate)           { predicate: ... }        row group 1  (skip)
                                                       ...
   IR::Filter          ->  IR::Scan              ->  row group 7  (read,
     (predicate)           { predicate: ... }        decode, filter rows)
                                                       ...
                          |
                          |  aexpr_to_skip_batch_predicate
                          v
                     IR::Scan
                     { predicate: (a_min > 3) | (a_max < 3) | ... }
                          |
                          |  evaluated over a frame with one row
                          |  per row group
                          v
                     Bitmap { 1, 1, 0, 0, 1, 0, 0, 0 }   (1 = skip)

The last line is the entire trick: a second compilation of the same expression, over a namespace of statistics columns, evaluated once per row group.

The optimizer hands the filter to the scan

The rewrite happens in the PredicatePushDown optimizer rule, in the Scan arm of the plan traversal. The rule walks the plan from the top, accumulating predicates in a map keyed by the leaf columns they read, and pushes them towards the leaves. When it reaches a scan, it merges the accumulated predicates with whatever the scan already had and stores the result on the scan node:

rust
            Scan {
                sources,
                file_info,
                hive_parts: scan_hive_parts,
                ref predicate,
                predicate_file_skip_applied,
                scan_type,
                unified_scan_args,
                output_schema,
                maintain_order,
            } => {

crates/polars-plan/src/plans/optimizer/predicate_pushdown/mod.rs:364

rust
                let predicate = combine_predicates(
                    Option::into_iter(predicate.clone()).chain(acc_predicates.into_values()),
                    expr_arena,
                );

                let do_optimization = predicate.is_some()
                    && match &*scan_type {
                        #[cfg(feature = "csv")]
                        FileScanIR::Csv { .. } => unified_scan_args.pre_slice.is_none(),
                        FileScanIR::ExpandedPaths { .. } => false,
                        FileScanIR::Anonymous { function, .. } => {
                            function.allows_predicate_pushdown()
                        },
                        #[allow(unreachable_patterns)]
                        _ => true,
                    };

crates/polars-plan/src/plans/optimizer/predicate_pushdown/mod.rs:386

and then, crucially, the filter node either disappears or survives above the scan:

rust
                let lp = if do_optimization {
                    Scan {
                        sources,
                        file_info,
                        hive_parts,
                        predicate,
                        predicate_file_skip_applied,
                        unified_scan_args,
                        output_schema,
                        scan_type,
                        maintain_order,
                    }
                } else {
                    let lp = Scan {
                        sources,
                        file_info,
                        hive_parts,
                        predicate: None,
                        predicate_file_skip_applied,
                        unified_scan_args,
                        output_schema,
                        scan_type,
                        maintain_order,
                    };
                    if let Some(predicate) = predicate {
                        let input = lp_arena.add(lp);
                        Filter { input, predicate }
                    } else {
                        lp
                    }
                };

crates/polars-plan/src/plans/optimizer/predicate_pushdown/mod.rs:429

Note the else branch: if the scan type does not support predicate pushdown, the predicate is put back as a plain IR::Filter above the scan. The pushdown is a move, not a copy — a filter that reaches a scan is no longer a filter node.

The expression is compiled a second time

Now the scan has a predicate expressed in terms of the data columns. The scanner, however, wants to ask a question about statistics: “could this row group possibly contain a row where a == 3?” Asking that with the data expression is impossible, because the data is what you are trying to avoid reading.

Polars solves this by translating the expression into a second expression over a synthetic schema. The module that does this is crates/polars-plan/src/plans/aexpr/predicates/skip_batches.rs, and the translation of col(A) == B is spelled out in the source as a comment:

rust
                        // col(A) == B -> {
                        //     null_count(A) == 0                              , if B.is_null(),
                        //     null_count(A) == LEN || min(A) > B || max(A) < B, if B.is_not_null(),
                        // }

crates/polars-plan/src/plans/aexpr/predicates/skip_batches.rs:351

which is then built, literally, out of three columns named A_min, A_max and A_nc plus a len column:

rust
                            not_null: {
                                let col_min = target.min(arena);
                                let col_max = target.max(arena);

                                let min_is_defined = is_stat_defined(col_min.node(), dtype, arena);
                                let max_is_defined = is_stat_defined(col_max.node(), dtype, arena);

                                let min_gt = col_min.gt(lv_node, arena);
                                let min_gt = min_gt.and(min_is_defined, arena);

                                let max_lt = col_max.lt(lv_node, arena);
                                let max_lt = max_lt.and(max_is_defined, arena);

                                let col_nc = target.null_count(arena);
                                let len = col!(len);
                                let all_nulls = col_nc.eq(len, arena);

                                all_nulls.or(min_gt, arena).or(max_lt, arena).node()
                            }

crates/polars-plan/src/plans/aexpr/predicates/skip_batches.rs:371

Read that as a boolean: this row group can be skipped if it is entirely null, or if its minimum is above B, or if its maximum is below B. The is_stat_defined guard exists because a null statistic means “unknown”, not “zero” — skipping on an unknown bound would drop real rows.

The translation is conservative by construction: any expression shape the translator does not recognise yields None, and None means “no skip predicate”, which means “read everything”. A rewrite that is wrong in the dangerous direction (skipping a row group that could contain a match) is a data-loss bug, so the translator is written to bail out rather than guess.

There is one subtlety worth calling out, because it is the kind of thing that silently corrupts results: floats. Parquet’s statistics exclude NaN, but the data may contain it, and NaN compares greater than everything under TotalOrd. So col < x is safe to prune on (a NaN never satisfies it) but col > x is not. The translator encodes that:

rust
/// Whether min/max statistics are usable for the given dtype, operator, and literal.
///
/// Rejects nested, null, and categorical types. For floats, Parquet stats exclude NaN
/// but data may contain it. Since NaN is largest under TotalOrd, `col < x` is safe
/// (NaN never matches) but `col > x` is not (NaN always matches).
/// col >= NaN matches NaN == NaN, but stats exclude NaN so max < NaN can't detect them -> unsafe
fn can_use_min_max_stats(
    dtype: &DataType,
    op: Option<&Operator>,
    lv: Option<&LiteralValue>,
) -> bool {
    if dtype.is_nested() || dtype.is_null() || dtype.is_categorical() {
        return false;
    }

    if !dtype.is_float() {
        return true;
    }

    let lv_is_nan = lv.is_some_and(|lv| lv.is_nan());

    use Operator as O;
    match op {
        Some(O::Lt | O::LtEq) => true,
        None | Some(O::Eq | O::EqValidity) => !lv_is_nan && lv.is_some(),
        Some(O::Gt) => lv_is_nan,
        _ => false,
    }
}

crates/polars-plan/src/plans/aexpr/predicates/skip_batches.rs:205

The statistics frame

The skip predicate is an ordinary Polars expression, so it needs an ordinary schema. The scan builds that schema when it constructs its predicate, giving every live column three statistics columns plus one row count:

rust
            let mut skip_batch_schema = Schema::with_capacity(1 + live_columns.len());

            skip_batch_schema.insert(PlSmallStr::from_static("len"), IDX_DTYPE);
            for (col, dtype) in schema.iter() {
                if !live_columns.contains(col) {
                    continue;
                }

                skip_batch_schema.insert(format_pl_smallstr!("{col}_min"), dtype.clone());
                skip_batch_schema.insert(format_pl_smallstr!("{col}_max"), dtype.clone());
                skip_batch_schema.insert(format_pl_smallstr!("{col}_nc"), null_count_dtype(dtype));
            }

            skip_batch_predicate = Some(create_physical_expr(
                &expr,
                expr_arena,
                &Arc::new(skip_batch_schema),
                state,
            )?);

crates/polars-mem-engine/src/scan_predicate/functions.rs:161

So the whole translation reduces to: the skip predicate is a Polars expression over a frame with one row per row group and columns named <col>_min, <col>_max, <col>_nc and len. No new evaluator, no new predicate language, no special-casing in the expression engine.

The statistics the reader gets from the footer are not directly usable as Polars values. They are stored in the file’s physical type, they may be inexact, and the file may declare a sort order that is not the one Polars compares with. crates/polars-parquet/src/arrow/read/statistics/bounds.rs handles all of that, and the guard is a single function:

rust
/// 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,
    }
}

crates/polars-parquet/src/arrow/read/statistics/bounds.rs:151

This is the piece that makes the whole scheme safe. A file that stores an Int64 column but declares Unsigned order, or declares no order at all, cannot be pruned with the statistics: the recorded maximum might not be the numerically largest value. Polars simply refuses to prune in that case.

The conversion itself drops bounds that do not survive it:

rust
    /// Converts the bounds of a chunk of the leaf. A timestamp the reader
    /// multiplies past `i64` wraps, leaving the chunk's values without order:
    /// such a conversion needs both bounds, and both within range, to hold.
    pub fn convert_bounds<'a>(
        self,
        min: Option<PhysicalBound<'a>>,
        max: Option<PhysicalBound<'a>>,
    ) -> ParquetResult<(Option<ArrowBound<'a>>, Option<ArrowBound<'a>>)> {
        let convert = |bound: Option<_>| bound.map(|b| self.convert(b)).transpose();
        let (min, max) = (convert(min)?.flatten(), convert(max)?.flatten());
        if self.can_wrap() && (min.is_none() || max.is_none()) {
            return Ok((None, None));
        }
        Ok((min, max))
    }

crates/polars-parquet/src/arrow/read/statistics/bounds.rs:272

A nanosecond timestamp stored as milliseconds is multiplied by a million on the way in, which can wrap i64. If it wrapped, the values in the row group have no order, and a “maximum” that is smaller than the “minimum” proves nothing. So the conversion throws both bounds away rather than compare garbage.

The pruning loop

With the statistics in hand, the reader builds a frame with one row per row group and runs the skip predicate on it. The mask is computed in parallel on the compute runtime, because it is pure arithmetic over a handful of thousands of rows:

rust
        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));

        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)

crates/polars-stream/src/nodes/io_sources/parquet/statistics.rs:184

evaluate_with_stat_df returns a bitmap in which a set bit means “this row group can be skipped”. The mask is then turned into the list of row groups that actually get fetched — and this is the loop that does the saving:

rust
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
}

crates/polars-stream/src/nodes/io_sources/parquet/row_group_data_fetch.rs:65

Everything downstream — the byte-range fetcher, the decompressors, the decoders — is driven from that VecDeque. A row group that is not in the deque is never fetched, never decompressed and never decoded.

The flag that says “don’t apply it again”

Pruning a row group is not the same as filtering rows. A row group that survives the statistics check can still contain rows that fail the predicate, so the predicate has to be evaluated on the decoded rows as well. The scanner does both, and the difference is what filters_rows records.

There are two cases in which the predicate does not filter rows. The interesting one is a predicate that only reads hive partition columns: it decides which files to read, not which rows to keep. In that case the scan predicate is built with the row-level part replaced by true:

rust
    let mut phys_predicate = create_physical_expr(&predicate, expr_arena, schema, state)?;

    // The hive predicate settles whole files; nothing is left to filter rows by.
    if hive_predicate_is_full_predicate {
        hive_predicate = Some(phys_predicate.clone());
        filters_rows = false;
        let node = expr_arena.add(AExpr::Literal(Scalar::from(true).into()));
        predicate = ExprIR::from_node(node, expr_arena);
        phys_predicate = create_physical_expr(&predicate, expr_arena, schema, state)?;
    }

crates/polars-mem-engine/src/scan_predicate/functions.rs:126

filters_rows travels with the predicate all the way into the reader, and the reader uses it to decide whether to build a mask at all:

rust
        let df = if let Some(predicate) = self
            .predicate
            .as_ref()
            .filter(|p| p.filters_rows && !nothing_to_evaluate)
        {
            let mask = predicate.predicate.evaluate_io(&df)?;
            let mask = mask.bool().unwrap();

crates/polars-stream/src/nodes/io_sources/parquet/row_group_decode.rs:231

The plan-level counterpart of the same idea is PredicateFileSkip, which the IR::Scan node carries to say “the file-level skip already settled this predicate”:

rust
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,
}

crates/polars-plan/src/dsl/file_scan/mod.rs:719

It is set when the file-level pruning resolved the whole predicate, and it also serves as a “do not do this again” marker for the optimizer:

rust
    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(),
        };

        if verbose {
            eprintln!("apply_scan_predicate_to_scan_ir: {predicate_file_skip:?}");
        }

        *predicate_file_skip_applied = Some(predicate_file_skip);

crates/polars-mem-engine/src/scan_predicate/functions.rs:563

and the guard at the top of the same function refuses to redo the work:

rust
    if unified_scan_args.has_row_index_or_slice() || predicate_file_skip_applied.is_some() {
        return Ok(());
    }

crates/polars-mem-engine/src/scan_predicate/functions.rs:525

no_residual_predicate is also what lets projection pushdown take the fast “count the rows without reading them” path for CSV: if the predicate has already been settled at the file level, the scan no longer has a row filter to apply, so the count can be taken from the file metadata.

The bottom line

The trick is that there is no second predicate language. Polars reuses its own expression engine, its own type coercion and its own null semantics, and pays for it by rewriting the expression into a namespace of statistics columns — col_min, col_max, col_nc — that the Parquet reader knows how to fill in with one row per row group.

What it buys: whole row groups are never read. That is not a constant-factor win, it is an I/O win, and for a selective predicate on a well-clustered file it can be the difference between reading 1% of the file and reading all of it.

What it costs:

  • The skip predicate is conservative by design. It only skips when the statistics prove no row can match. A file with no statistics, a file whose statistics are inexact, a file with a non-default sort order, a categorical column — all of them disable pruning for that column.
  • Statistics-based pruning is only as good as the data layout. A file written in random order has overlapping min/max ranges in every row group and nothing gets pruned. The win comes from sorted or clustered data.
  • Two predicate evaluations, not one. The statistics predicate runs once per row group; the row predicate still runs on the rows of the row groups that survive. A predicate that cannot be expressed over statistics costs you the translation and buys you nothing.
  • The rewrite is not free. Every pushdown-able expression gets a second compilation, and the expression engine has to be able to evaluate a comparison against a nullable statistic column without any special casing.

The practical takeaway is that predicate pushdown into the Parquet scanner is not a single feature but a chain: the optimizer moves the filter onto the scan, the scan compiles the filter into a statistics predicate, the reader evaluates that predicate against the footer, and the row groups that fail it never leave the metadata stage.

PolarsInternalsOptimizerParquetPerformance