Polars Publishes a Join's Build Side as a Runtime Filter

How Polars turns the build side of a hash join into a min/max range and bloom filter that the probe side's scan uses to skip row groups.

TL;DR: A hash join knows exactly which keys it holds, and the probe side usually does not need to look at rows whose keys cannot be in that set. Polars makes this work by turning the join key on the probe side into a DynamicPred — a predicate slot with no value at plan time — pushing it into the scan below the probe, and having the join fill the slot with the build side’s min/max range (and, when it pays, a bloom filter) once the hash table is built. The scan then skips whole row groups by their statistics and drops the rows outside the range.

The join runtime filter is the most interesting optimizer pass in Polars, because it is not really an optimizer pass at all. It does not rewrite a plan into a cheaper plan; it builds a communication channel between two operators that are far apart in the tree, and then fills that channel at run time with information that only exists once the data has been read.

The module that implements it explains itself better than I could:

text
        plan time                              run time
   ----------------                         -------------
   join key on the probe side             build side is hashed
        |                                      |
        v                                      v
   trace the key down                   min/max of the build keys
   to a parquet scan                    (+ a bloom filter, maybe)
        |                                      |
        v                                      |
   DynamicPred slot  <--------- set ----------+
   (no value yet)                          (the range)
        |
        v
   parquet scan under the probe side:
     - skip row groups whose stats fall outside the range
     - drop rows whose value falls outside the range
rust
//! Let a hash join tell the parquet scan under its probe side which keys the build
//! side holds.
//!
//! A join whose one side is known to be much smaller than a scan under its other
//! side gets that side forced as build side; one whose side is only estimated to be
//! much smaller gets it preferred. Every probe key that
//! reads a parquet scan column unchanged gets a dynamic predicate on that scan. The
//! join publishes the range of its build keys once the build is done. The scan
//! skips the row groups outside the range and drops the rows outside it. When the
//! build keys are estimated to be a small share of the scan's distinct keys the
//! join also publishes a bloom filter over them, which the scan probes per row
//! instead.

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:1

The plan-time side: finding a scan to talk to

The pass runs after the join build side has been chosen, and it starts by deciding whether there is anything to talk to at all:

rust
pub(super) fn attach_join_runtime_filters(
    root: Node,
    ir_arena: &mut Arena<IR>,
    expr_arena: &mut Arena<AExpr>,
) {
    if !polars_config::config().join_runtime_filters() {
        return;
    }
    // Inputs before their join, so a join lower in a probe chain is forced before
    // an outer one tries to carry a predicate through it.
    let mut joins = Vec::new();
    let mut has_pruning_scan = false;
    let mut stack = vec![root];
    while let Some(node) = stack.pop() {
        let ir = ir_arena.get(node);
        match ir {
            IR::Join { .. } => joins.push(node),
            IR::Scan { scan_type, .. } => has_pruning_scan |= skips_batches(scan_type),
            _ => {},
        }
        ir.copy_inputs(&mut stack);
    }
    if !has_pruning_scan {
        return;
    }

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:79

Two early exits define the scope of the whole feature. The first is a config flag (POLARS_JOIN_RUNTIME_FILTERS, on by default). The second is has_pruning_scan: the only scans that can use a range are ones that skip batches by their statistics, which in practice means parquet scans with row-group statistics:

rust
fn skips_batches(scan_type: &FileScanIR) -> bool {
    scan_type
        .flags()
        .contains(ScanFlags::SKIPS_BATCHES_BY_STATISTICS)
}

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:475

A CSV scan cannot use a range, so a plan with no parquet scan under the probe side is left completely alone.

Which joins qualify

Not every join can publish a filter. The constraints are in one function:

rust
/// Whether the join may publish a filter or be crossed by one: an inner, semi or
/// anti equi join the streaming engine can run as a hash join that blocks its
/// probe side until the build is done. Sorted inputs may still make it a merge
/// join, which drops the filter. A key that may evaluate differently each time
/// gives no filter, as the join evaluates it again when it builds.
fn is_eligible_join(options: &JoinOptionsIR, expr_arena: &Arena<AExpr>) -> bool {
    let args = &options.args;
    let JoinTypeOptionsIR::Equi { on, .. } = &options.options else {
        return false;
    };
    !on.is_empty()
        && on.iter().all(|(left, right)| {
            !is_inherently_nondeterministic(left.node(), expr_arena)
                && !is_inherently_nondeterministic(right.node(), expr_arena)
        })
        && (args.how == JoinType::Inner || args.how.is_semi_anti())
        && args.maintain_order == MaintainOrderJoin::None
        && !args.nulls_equal
        && args.slice.is_none()
        && !args.validation.needs_checks()
}

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:334

Every clause is a correctness requirement:

  • Equi only. A range is only meaningful for equality keys.
  • Inner, semi or anti. A left or right outer join keeps unmatched rows from one side, so pruning the probe side would drop rows the join is supposed to emit.
  • No maintain_order. Order maintenance changes which side is read first, and the whole scheme depends on the probe side being read after the build.
  • No nulls_equal. The published range excludes nulls; a join that treats nulls as equal would need nulls in the range.
  • No slice. A slice takes the first n rows of the join output, and pruning the probe side would change which rows those are.
  • Deterministic keys. The join evaluates the key expression again when it builds, and the scan evaluates it when it reads. If the two evaluations could differ, the published range would describe a different set of keys than the hash table holds.

Semi and anti joins get a special rule, stated in the module docs:

rust
//! A semi join publishes from either side and an anti join from its left side
//! only, as the rows of its right side that match no left key change nothing. A
//! semi join may prefer its right side on an estimate; a left side is only built
//! when forced, as building it keeps its rows. It is not forced when the right keys
//! are known to be much fewer than its estimated rows, as a right build keeps only
//! those.

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:30

Which side becomes the build

The pass picks the build side itself, and it does so from statistics rather than from a user hint. build_candidates produces, for each side, a forced candidate if the side has a hard row bound, and a preferred candidate from its estimate:

rust
/// The ways a filtered side may be built from: forced when it has a bound, and
/// preferred on its estimate.
fn build_candidates(left: bool, stats: &NodeStats) -> Vec<BuildCandidate> {
    if stats.filtered >= stats.unfiltered {
        return Vec::new();
    }
    let forced = stats.max_rows().map(|rows| BuildCandidate {
        left,
        rows,
        forced: true,
    });
    let preferred = BuildCandidate {
        left,
        rows: stats.filtered,
        forced: false,
    };
    forced.into_iter().chain([preferred]).collect()
}

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:273

A side that is filtered below its unfiltered size is the only candidate — an unfiltered side has no interesting range, because its range is just the range of the whole column. The “lopsided” threshold is a factor of eight:

rust
/// A side has to be this many times smaller to be picked as the build side.
pub(super) const LOPSIDED_FACTOR: f64 = 8.0;

crates/polars-plan/src/plans/optimizer/join_build_side.rs:14

and the candidate is only taken if the scan under the probe side is big enough for the pruning to be worth it:

rust
        (!filters.is_empty() && *probe_rows >= LOPSIDED_FACTOR * side.rows).then_some(side)

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:173

Tracing the key to a scan

This is the part that makes the feature work: the join’s key on the probe side has to reach a scan that reads that column unchanged. If the scan applies a filter to the key column first, the values that reach the join are not the values in the file, and a range published from the build side would be wrong for the filtered values.

trace_probe_keys walks the probe input and collects the scans each key reaches:

rust
fn trace_probe_keys(
    probe_input: Node,
    probe_keys: Vec<Option<PlSmallStr>>,
    ir_arena: &Arena<IR>,
    expr_arena: &mut Arena<AExpr>,
    scratch: &mut UnitVec<Node>,
) -> Vec<TracedKey> {
    let probe_schema = ir_arena.get(probe_input).schema(ir_arena).into_owned();
    let mut traced = Vec::new();
    for (key_idx, name) in probe_keys.into_iter().enumerate() {
        let Some(name) = name else { continue };
        if !probe_schema.get(&name).is_some_and(supports_runtime_range) {
            continue;
        }
        let column = expr_arena.add(AExpr::Column(name));
        let (dyn_node, pred) = new_dynamic_pred(column, expr_arena, None);
        let predicate = ExprIR::from_node(dyn_node, expr_arena);
        let origins = scan_origins(probe_input, predicate, ir_arena, expr_arena, scratch);

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:317

scan_origins is the actual traversal. It pushes a predicate down through the nodes a predicate can be pushed past, follows the column through a union into every input, and stops at anything that would change the column’s values:

rust
                IR::SimpleProjection { .. }
                | IR::Filter { .. }
                | IR::Select { .. }
                | IR::HStack { .. } => {
                    match push_past(
                        node,
                        &mut predicate,
                        ir_arena,
                        expr_arena,
                        scratch,
                        maintain_errors,
                    ) {
                        Ok(Some(input)) => node = input,
                        _ => continue 'paths,
                    }
                },
                // A slice over the union would take other rows once some are pruned.
                IR::Union { inputs, options } if options.slice.is_none() => {
                    // Each input rewrites its own expression nodes; the filter they
                    // refer to stays the one the join publishes.
                    for &input in inputs.iter().rev() {
                        let copy = deep_clone_ae(predicate.node(), expr_arena);
                        paths.push((input, ExprIR::from_node(copy, expr_arena)));
                    }
                    continue 'paths;
                },

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:407

Two of the guards are worth reading closely. A Filter is passed through by push_past, which also rewrites the predicate so it refers to the same column after the filter — so the filter’s own restriction is preserved in the predicate that ends up on the scan. But a filter containing a window function is a barrier, because a window function is lowered to a group-by that reads the scan on its own:

rust
                IR::Filter {
                    predicate: filter, ..
                } if has_window(std::slice::from_ref(filter), expr_arena) => continue 'paths,

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:384

A union is followed into every input, and each input gets its own copy of the predicate expression — but they all refer to the same DynamicPred, so they all see the same published filter. A union with a slice is excluded, because pruning one input would shift the rows the slice takes.

Attaching the slot to the scan

Once a scan is found, the predicate is attached to it, combined with any predicate the scan already had:

rust
fn attach_to_scan(
    scan: Node,
    predicate: ExprIR,
    ir_arena: &mut Arena<IR>,
    expr_arena: &mut Arena<AExpr>,
) {
    let IR::Scan {
        predicate: existing,
        predicate_file_skip_applied,
        ..
    } = ir_arena.get_mut(scan)
    else {
        unreachable!()
    };
    // Files are skipped by the scan predicate after these filters are attached.
    debug_assert!(predicate_file_skip_applied.is_none());
    *existing = Some(match existing.take() {
        None => predicate,
        Some(existing) => {
            let node = expr_arena.add(AExpr::BinaryExpr {
                left: existing.node(),
                op: Operator::And,
                right: predicate.node(),
            });
            ExprIR::from_node(node, expr_arena)
        },
    });
}

crates/polars-plan/src/plans/optimizer/join_runtime_filter.rs:503

The predicate is an ordinary expression in the plan — a DynamicPred function node wrapping a column reference — which means it flows through the normal expression machinery. Its value, though, is not in the plan. That is what DynamicPred is for:

rust
/// A predicate over `node` whose value a producer sets at run time, evaluated
/// per row wherever it lands. A reader reads in `read_order` when it can.
pub fn new_dynamic_pred(
    node: Node,
    arena: &mut Arena<AExpr>,
    read_order: Option<ReadOrder>,
) -> (Node, DynamicPred) {
    let pred = DynamicPred::new(read_order);
    let function = IRFunctionExpr::DynamicPred {
        pred: pred.downgrade(),
    };
    let options = function.function_options();
    let aexpr = AExpr::Function {
        input: vec![ExprIR::from_node(node, arena)],
        function,
        options,
    };

    (arena.add(aexpr), pred)
}

crates/polars-plan/src/plans/optimizer/predicate_pushdown/dynamic.rs:246

The DynamicPred is a shared, mutable slot: an Arc<Inner> with an RwLock<Option<Arc<dyn PredicateExpr>>> and an AtomicBool that says whether the value has been set:

rust
    pub fn set(&self, pred: Arc<dyn PredicateExpr>) {
        {
            let mut guard = self.inner.pred.write().unwrap();
            *guard = Some(pred);
        }
        self.inner.is_set.store(true, Ordering::Release);
    }

    pub fn is_set(&self) -> bool {
        self.inner.is_set.load(Ordering::Acquire)
    }

crates/polars-plan/src/plans/optimizer/predicate_pushdown/dynamic.rs:143

Before it is set, the predicate evaluates to “everything passes”, so the scan is correct whether or not the join ever publishes anything.

The run-time side: what the join publishes

The plan carries a list of these filters on the join node:

rust
/// The build-side key at `key_idx` of the join's `on`, published through
/// `pred` for the probe side: its range, and a bloom filter over its values
/// when `bloom_keys` gives the number of distinct keys to size it for.
#[derive(Clone, Debug, PartialEq, Hash)]
#[cfg_attr(feature = "ir_serde", derive(Serialize, Deserialize))]
pub struct RuntimeFilter {
    pub key_idx: usize,
    pub pred: DynamicPred,
    pub bloom_keys: Option<usize>,
    /// The probe side's distinct keys, when the plan can estimate them.
    pub probe_distinct: Option<usize>,
}

crates/polars-plan/src/plans/options.rs:414

bloom_keys is the plan’s estimate of how many distinct keys the build side holds, and probe_distinct is the plan’s estimate of how many distinct keys the probe side has. Whether to build a bloom filter at all depends on the ratio:

rust
/// Largest ratio of distinct build keys to distinct probe keys for which a
/// bloom filter is published.
pub const MAX_BUILD_PROBE_DISTINCT_RATIO: f64 = 0.3;

crates/polars-plan/src/plans/options.rs:402

A bloom filter over the build keys is only worth probing if the build keys are a small fraction of the probe’s distinct keys. If the build side has almost as many distinct keys as the probe side, the filter would reject almost nothing and only cost time.

At run time, RuntimeFilters owns one builder per filter. The builder tracks two things: the min/max of the key column, and (optionally) the hashes of the key values:

rust
pub(super) struct KeyFilterBuilder {
    range: KeyRange,
    bloom: Option<BloomBuilder>,
}

crates/polars-stream/src/nodes/joins/runtime_filter.rs:219

The range is just the running min and max:

rust
impl KeyRange {
    /// Widen the range to cover the non-null values of `column`.
    fn extend(&mut self, column: &Column) -> PolarsResult<()> {
        let min = column.min_reduce()?;
        let max = column.max_reduce()?;
        if !min.is_null() && !max.is_null() {
            self.merge(Self {
                bounds: Some((min, max)),
            });
        }
        Ok(())
    }

crates/polars-stream/src/nodes/joins/runtime_filter.rs:270

and the published filter evaluates it per row with two comparisons:

rust
impl PredicateExpr for KeyRange {
    /// Whether each value lies in the range. Nulls never do.
    fn evaluate(&self, columns: &[Column]) -> PolarsResult<Option<Column>> {
        let Some(bounds) = &self.bounds else {
            return Ok(None);
        };
        let column = columns[0].as_materialized_series();
        let Some((lo, hi)) = bounds_for(bounds, column.dtype()) else {
            return Ok(None);
        };
        let mask = (column.gt_eq(&lo)? & column.lt_eq(&hi)?).fill_null_with_values(false)?;
        Ok(Some(mask.into_column()))
    }

crates/polars-stream/src/nodes/joins/runtime_filter.rs:488

Note fill_null_with_values(false): a null key never matches a join key, so it never matches the range.

The same filter also knows how to skip a whole batch of rows using the batch’s statistics:

rust
    fn evaluate_stats(
        &self,
        min: &Column,
        max: &Column,
        _null_count: &Column,
    ) -> PolarsResult<Option<Column>> {
        let Some(bounds) = &self.bounds else {
            return Ok(Some(Column::new_scalar(
                min.name().clone(),
                Scalar::from(true),
                min.len(),
            )));
        };
        let min = min.as_materialized_series();
        let max = max.as_materialized_series();
        let Some((lo, hi)) = bounds_for(bounds, min.dtype()) else {
            return Ok(None);
        };
        // A batch is skipped when it lies entirely below or above the range. An
        // unknown statistic is null and settles nothing.
        let skip = max.lt(&lo)? | min.gt(&hi)?;
        Ok(Some(skip.fill_null_with_values(false)?.into_column()))
    }

crates/polars-stream/src/nodes/joins/runtime_filter.rs:507

That is the row-group skip: if the batch’s maximum is below the range’s minimum, or its minimum is above the range’s maximum, no row in it can match, and the reader never decodes it.

When the filter is published

There are two moments at which the join can publish, and the distinction matters.

A forced build side is read completely before the probe side is opened, so its range is known before the scan even starts. A preferred build side is only preferred, so the join samples both sides at run time and decides. The sampling code makes the ordering explicit:

rust
    fn try_transition_to_build(
        &mut self,
        recv: &[PortState],
        params: &mut EquiJoinParams,
        state: &StreamingExecutionState,
        spill_ctx: &MostRecentSpillContext,
    ) -> PolarsResult<Option<BuildState>> {
        if let Some(left) = self.only_side {
            let idx = if left { 0 } else { 1 };
            let len = self.len(left);
            if len >= params.sample_limit {
                if config::verbose() {
                    eprintln!("preferred build side reached the sample limit, sampling both sides");
                }
            } else if recv[idx] == PortState::Done {
                if config::verbose() {
                    eprintln!("preferred build side done with {len} rows, publishing its ranges");
                }
                self.publish_runtime_filters(left, params, state)?;

crates/polars-stream/src/nodes/joins/equi_join.rs:437

If the preferred side ends before it reaches the sample limit, it is complete, and its ranges are published before the other side is read at all. If it reaches the limit, both sides are sampled and the filter is only published if the preferred side ends up being the build side. The result is always exact; only the pruning is lost in the second case.

Once the build is done, the local builders are merged and published:

rust
    /// Hand every build key to the runtime filters. Filters set from a sample
    /// keep their value, whichever side is built; filters of a side that was
    /// not built get a predicate that skips nothing.
    fn publish_runtime_filters(&mut self, params: &mut EquiJoinParams) {
        if !params.publishes_runtime_filters() {
            params.runtime_filters.publish_nothing();
            return;
        }
        let locals = self
            .local_builders
            .iter_mut()
            .map(|l| std::mem::take(&mut l.key_filters));
        params.runtime_filters.publish_merged(locals);
    }

crates/polars-stream/src/nodes/joins/equi_join.rs:761

publish_nothing is the important case: if the join ends up building the other side, the filter that was prepared for this side must be filled with something that rejects nothing, or the scan would drop rows it should keep.

The keys themselves are collected while the build side is read:

rust
        let locals = RAYON.install(|| {
            morsels
                .par_chunks(chunk_len)
                .map(|chunk| {
                    let mut builders = self.new_builders();
                    for morsel in chunk {
                        let df = morsel.df_blocking();
                        let keys = ASYNC.block_on(select_key_columns(&df, key_selectors, state))?;
                        self.extend(&keys, &mut builders)?;
                    }
                    PolarsResult::Ok(builders)
                })
                .collect::<PolarsResult<Vec<_>>>()
        })?;
        self.publish_merged(locals);

crates/polars-stream/src/nodes/joins/runtime_filter.rs:109

The bloom filter

The bloom filter is the more expensive half of the published filter, and it has a budget. Each builder buffers key hashes until the total number of build rows exceeds a budget, at which point they all switch to a shared bloom filter of the size the plan estimated:

rust
/// Bits per key a bloom filter is sized for.
const BLOOM_BITS_PER_KEY: usize = 8;
/// A bloom filter with fewer bits per distinct key than this is not published.
const BLOOM_MIN_BITS_PER_KEY: usize = 4;
/// Smallest bloom filter that is built.
const BLOOM_MIN_BYTES: usize = 64 << 10;
/// Largest bloom filter that is published.
const BLOOM_MAX_BYTES: usize = 32 << 20;

crates/polars-stream/src/nodes/joins/runtime_filter.rs:30

The point of the budget is that a bloom filter sized from the actual distinct keys is better than one sized from the plan’s estimate, but only if you can afford to hold the hashes until you know the count. The CardinalitySketch tracks the distinct count cheaply while the hashes are buffered, so the filter can be sized exactly at publication time:

rust
        let distinct = sketch.estimate();
        let weak = spec
            .probe_distinct
            .is_some_and(|probe| distinct as f64 > probe as f64 * MAX_BUILD_PROBE_DISTINCT_RATIO);
        let bloom = if weak {
            None
        } else if planned {

crates/polars-stream/src/nodes/joins/runtime_filter.rs:328

and if the real distinct count turns out to be too large relative to the probe side’s, the bloom filter is dropped at the last moment rather than published and probed for nothing.

The published filter then probes the bloom per row instead of the range:

rust
    fn evaluate(&self, columns: &[Column]) -> PolarsResult<Option<Column>> {
        let Some(KeyBloom { spec, bloom }) = &self.bloom else {
            return self.range.evaluate(columns);
        };
        let column = &columns[0];
        if column.dtype() != &spec.dtype {
            return Ok(None);
        }
        let mut mask = BitmapBuilder::with_capacity(column.len());
        spec.hash_keys(column).for_each_hash(|_, hash| {
            mask.push(hash.is_some_and(|hash| bloom.contains(hash)));
        });
        Ok(Some(
            BooleanChunked::from_bitmap(column.name().clone(), mask.freeze()).into_column(),
        ))
    }

crates/polars-stream/src/nodes/joins/runtime_filter.rs:402

The bloom filter is in addition to the range, not instead of it: the range is what allows whole row groups to be skipped, the bloom is what catches the rows inside the range that are not in the build side.

The consumer: the scan

The predicate lands on the scan as an ordinary expression, and the expression engine lowers it twice: once into a per-row filter, and once into a statistics-level skip.

The per-row path is DynamicPred:

rust
        F::DynamicPred { pred, .. } => {
            map_as_slice!(misc::dynamic_pred, &pred)
        },

crates/polars-expr/src/dispatch/mod.rs:571

and the statistics path is DynamicSkipBatch, which the skip-batch lowering derives from it:

rust
                IRFunctionExpr::DynamicPred { pred, .. } => {
                    let target = resolve_stat_target(input[0].node(), arena, schema)?;
                    let dtype = target_leaf_dtype(&target, schema)?;
                    if !supports_runtime_range(dtype) {
                        return None;
                    }
                    let function = IRFunctionExpr::DynamicSkipBatch { pred: pred.clone() };
                    let options = function.function_options();
                    let input = [
                        target.min(arena),
                        target.max(arena),
                        target.null_count(arena),
                    ]
                    .into_iter()
                    .map(|b| ExprIR::from_node(b.node(), arena))
                    .collect();

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

The dtype restriction is explicit:

rust
/// Whether a runtime key range may skip batches of a column with this dtype.
///
/// The range is compared against the batch's min/max with polars' ordering, so the
/// type must be one whose parquet statistics are ordered the same way. Floats are out
/// because the statistics leave out NaN; 128-bit integers because their statistics
/// are byte-wise.
pub(crate) fn supports_runtime_range(dtype: &DataType) -> bool {
    use DataType as D;
    match dtype {
        D::Boolean
        | D::UInt8
        | D::UInt16
        | D::UInt32
        | D::UInt64
        | D::Int8
        | D::Int16
        | D::Int32
        | D::Int64
        | D::String
        | D::Binary
        | D::Date
        | D::Datetime(..)
        | D::Duration(_)
        | D::Time => true,
        #[cfg(feature = "dtype-decimal")]
        D::Decimal(..) => true,
        _ => false,
    }
}

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

Floats are excluded because parquet’s min/max statistics leave out NaN, and NaN compares strangely; 128-bit integers are excluded because their statistics are byte-wise rather than numeric.

The scan side of the contract is a small enum:

rust
pub enum RuntimeRange {
    /// Not published yet. Every batch is kept; a later file may see a range.
    Pending,
    /// Never published. Every batch is kept.
    Disabled,
    /// No value can match. Every batch is skipped.
    Empty,
    /// Only values in `lo..=hi` can match, a missing bound does not limit that
    /// side. Nulls match when `nulls_match`.
    Range {
        lo: Option<Scalar>,
        hi: Option<Scalar>,
        nulls_match: bool,
    },
}

crates/polars-io/src/predicates.rs:509

Pending and Disabled are different on purpose: Pending means “not yet, but maybe later”, so a reader keeps every batch of the current file but checks again for the next one; Disabled means “never”. The parquet reader resolves the range against each file’s statistics and skips the row groups that fall outside it.

One honest limitation

The runtime filter is a streaming engine feature. runtime_filters lives on JoinOptionsIR in polars-plan, but the only consumer is polars-stream: the physical plan nodes that read it are EquiJoin and SemiAntiJoin in crates/polars-stream/src/physical_plan/mod.rs, and crates/polars-mem-engine never looks at the field. So on the classic in-memory engine the optimizer pass still runs and still rewrites the plan, but nothing fills the slot and nothing reads it — the predicate evaluates to “everything passes”.

The bottom line

  • The mechanism is a shared slot, not a plan rewrite. DynamicPred is a predicate with no value at plan time; the join fills it at run time.
  • Eligibility is strict. Inner, semi or anti equi joins, no order maintenance, no nulls-equal, no slice, deterministic keys, and a scan that skips batches by statistics. Everything else is left alone.
  • The trace is the hard part. A key only qualifies if it reaches a scan unchanged; filters are pushed past (and the predicate rewritten to match), window functions and sliced unions are barriers, and a union’s inputs all share one published filter.
  • The build side is chosen from statistics, forced when there is a hard row bound, preferred otherwise, and only when the probe-side scan is at least eight times bigger.
  • What is published is a range and, conditionally, a bloom filter. The range skips row groups by statistics and drops rows outside it; the bloom filter, published only when the build keys are under 30% of the probe’s distinct keys, catches the rows inside the range that are not in the build side.
  • It is a streaming-engine feature. The in-memory engine ignores it.

For a user the lever is POLARS_JOIN_RUNTIME_FILTERS (on by default), and the shape of the data is what decides whether it pays: a join against a parquet scan with a very selective small build side is exactly the case this was built for.

PolarsInternalsJoinsOptimizerParquet