DataFusion's Constant Folding Trick: Evaluate the Expression Against a Dummy Row of NULLs

To constant-fold a physical expression, DataFusion evaluates it against a one-row batch of NULLs and checks whether the result is a scalar, refusing to fold volatile or oversized regex expressions.

TL;DR: To know whether an expression is constant, you can evaluate it. But a physical expression’s evaluate API demands a RecordBatch, and there is no batch to hand it at plan time. DataFusion’s answer is to build a one-row batch whose single column is a NULL of type Null, and evaluate against that. If the result comes back as a Scalar (rather than an array), the expression is constant and gets replaced by a literal. The trick is guarded by three checks: volatile functions are never folded, leaf nodes are never folded, and a regex that would exceed the planner’s size budget is never compiled at plan time.

Constant folding is the oldest optimisation in query engines. 1 + 2 is 3. length('hello') is 5. A predicate that folds to false deletes an entire subtree from the plan.

DataFusion does this in two places — the logical simplifier (datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs) and the physical simplifier (datafusion/physical-expr/src/simplifier/const_evaluator.rs). The physical one is more interesting, because it has to work with the PhysicalExpr::evaluate API, which takes a RecordBatch.

The dummy batch

Here is the whole trick:

rust
/// Create a 1-row dummy RecordBatch for evaluating constant expressions.
///
/// The batch is never actually accessed for data - it's just needed because
/// the PhysicalExpr::evaluate API requires a RecordBatch. For expressions
/// that only contain literals, the batch content is irrelevant.
///
/// This is the same approach used in the logical expression `ConstEvaluator`.
pub(crate) fn create_dummy_batch() -> Result<&'static RecordBatch> {
    static DUMMY_BATCH: std::sync::OnceLock<Result<RecordBatch>> =
        std::sync::OnceLock::new();
    DUMMY_BATCH
        .get_or_init(|| {
            // RecordBatch requires at least one column
            let dummy_schema =
                Arc::new(Schema::new(vec![Field::new("_", DataType::Null, true)]));
            let col = new_null_array(&DataType::Null, 1);
            Ok(RecordBatch::try_new(dummy_schema, vec![col])?)
        })
        .as_ref()
        .map_err(|e| {
            internal_datafusion_err!(
                "Failed to create dummy batch for constant expression evaluation: {e}"
            )
        })
}

Four details, all of them deliberate:

One row. The batch has exactly one row. A constant expression’s result must not depend on the batch, so one row is enough to compute the value — and it makes “is the result an array of length 1?” a valid constness test.

One column, of type Null. A RecordBatch must have at least one column, so the batch carries a single null column named _. The type Null is the type that can hold literally any value, and new_null_array gives an all-null buffer with no data.

The content is irrelevant. The doc says so explicitly: “For expressions that only contain literals, the batch content is irrelevant.” The batch exists only to satisfy a type signature.

OnceLock. The batch is a static, initialised exactly once and shared by every caller. Constant folding happens during optimisation, which may be concurrent, and OnceLock gives a thread-safe, zero-cost -after-first-read way to have a &'static RecordBatch. That is a lovely detail: the return type is &'static RecordBatch because the batch is immutable and immortal.

The fold itself

rust
match expr.evaluate(batch) {
    Ok(ColumnarValue::Scalar(scalar)) => {
        Ok(Transformed::yes(Arc::new(Literal::new(scalar))))
    }
    Ok(ColumnarValue::Array(arr)) if arr.len() == 1 => {
        // Some operations return an array even for scalar inputs
        let scalar = ScalarValue::try_from_array(&arr, 0)?;
        Ok(Transformed::yes(Arc::new(Literal::new(scalar))))
    }
    Ok(_) => {
        // Unexpected result - keep original expression
        Ok(Transformed::no(expr))
    }
    Err(_) => {
        // On error, keep original expression
        // The expression might succeed at runtime due to short-circuit evaluation
        // or other runtime conditions
        Ok(Transformed::no(expr))
    }
}

Three outcomes: a scalar becomes a literal; a one-element array becomes a literal (some kernels return arrays even for scalar inputs); anything else is left alone.

And the error case has a lovely comment:

On error, keep original expression. The expression might succeed at runtime due to short-circuit evaluation or other runtime conditions

Which is to say: false AND unknown_column might be false at runtime if the engine short-circuits, but evaluating it at plan time against a dummy batch errors out. The conservative behaviour — don’t fold — is correct.

The three guards

The interesting engineering is not the fold, it is the three conditions that stop it from firing when it should not.

Guard 1: volatility

rust
// Volatile nodes cannot be evaluated at plan time
if expr.is_volatile_node() {
    return Ok(Transformed::no(expr));
}

random(), now(), uuid() — a volatile function returns a different value every time it is called. Folding random() > 0.5 into a literal would change the query’s meaning. DataFusion tracks volatility in the function’s Signature and the check is a property of the node, so it costs a downcast.

Guard 2: leaf nodes

This one is subtle, and it is the kind of bug that would take a long time to find:

rust
// Leaf nodes (zero children) are rejected here. Const-folding is only
// sound for a node whose value is fully determined by its child literals;
// a leaf has no children, so there is nothing to derive constness from.
// The known leaves that are constant (`Literal`) or known-non-constant
// (`Column`, volatile) are handled by the dedicated checks above. Any
// other leaf is opaque to the simplifier and must be preserved as-is,
// otherwise `all` over an empty child list would vacuously hold and the
// node would be evaluated against the dummy batch, producing a value
// unrelated to its real runtime semantics.
let children = expr.children();
if children.is_empty() || !children.iter().all(|c| c.is::<Literal>()) {
    return Ok(Transformed::no(expr));
}

The bug it prevents: children.iter().all(|c| c.is::<Literal>()) on a node with no children is vacuously true. So a leaf node that is not a Literal, not a Column, and not volatile — an opaque leaf, say a ScalarFunctionExpr wrapping a function with no arguments — would pass the “all children are literals” test and get evaluated against the dummy batch. The value that comes out would be a value, but unrelated to the function’s real runtime semantics.

There is a regression test for exactly this, with a link to the issue:

rust
/// Regression test for https://github.com/apache/datafusion/issues/22367.
///
/// A leaf `PhysicalExpr` that is neither a `Literal` nor a `Column`
/// (nor volatile) must not be const-folded: it has no children to
/// derive constness from, and evaluating it against the dummy batch
/// produces a value unrelated to its real runtime semantics. Without
/// the zero-children guard, `all(empty)` would vacuously hold and the
/// node would be replaced with whatever scalar fell out of the dummy
/// evaluation. Verify the node is left untouched.

This is the kind of bug that a reviewer catches and a fuzzer might not. The “vacuous truth of all over an empty list” is a classic.

Guard 3: the regex size limit

The third guard is the most interesting, and it is not about correctness at all — it is about not letting the query planner be a denial-of-service vector.

rust
fn should_evaluate_const_expr(expr: &Arc<dyn PhysicalExpr>) -> bool {
    ...
    let Some(binary) = expr.downcast_ref::<BinaryExpr>() else {
        return true;
    };
    if !matches!(
        binary.op(),
        Operator::RegexMatch
            | Operator::RegexNotMatch
            | Operator::RegexIMatch
            | Operator::RegexNotIMatch
    ) {
        return true;
    }
    let Some(pattern) = binary.right().downcast_ref::<Literal>() else {
        return true;
    };
    let Some(pattern) = pattern.value().try_as_str().flatten() else {
        return true;
    };

    let mut builder = RegexBuilder::new(pattern);
    builder.size_limit(REGEX_PLANNING_SIZE_LIMIT_BYTES);
    if matches!(
        binary.op(),
        Operator::RegexIMatch | Operator::RegexNotIMatch
    ) {
        builder.case_insensitive(true);
    }
    !matches!(builder.build(), Err(RegexError::CompiledTooBig(_)))
}

The logic: if this is a regex comparison with a literal pattern, try to compile the regex at plan time with a size limit, and refuse to fold if the regex is too big to compile.

Why? Because compiling a regex is expensive — the regex crate’s compilation is superlinear in the pattern size for pathological patterns, and it allocates a DFA/NFA program. If DataFusion constant-folded 'abc' ~ 'a{1000}{1000}{1000}...', the query planner would spend minutes compiling a regex — before the query even starts, and regardless of whether the regex is ever used.

The limit is a shared constant:

rust
// datafusion/expr/src/simplify.rs
pub const REGEX_PLANNING_SIZE_LIMIT_BYTES: usize = 256 * 1024;

and it appears in three places — the logical simplifier, the physical simplifier, and the regex function implementations themselves (datafusion/functions/src/regex/regexplike.rs). So the same budget applies whether the regex is compiled at plan time or at run time.

The subtlety here is that the check does not just look at the length of the pattern. It actually builds the regex with the limit and checks whether the builder returned CompiledTooBig. The regex crate’s size_limit is about the size of the compiled program, not the length of the pattern — a 30-character pattern can compile to a huge program, and a 10,000-character pattern can compile to a small one. So the only correct way to know whether a pattern is over budget is to compile it with the limit and see.

Which means the check is not free — it compiles the regex. But the regex has to be compiled eventually anyway, and at run time the compiled regex can be cached. The plan-time compile is the price of knowing.

The bottom-up trick

There is one more design decision worth noting, and it is about when the check happens.

rust
// Since transform visits bottom-up, children have already been simplified.
// If all children are now Literals, this node can be const-evaluated.
// This is O(k) where k = number of children, instead of O(subtree).

The simplifier uses a bottom-up traversal, so when it reaches a node, its children have already been folded into literals where possible. So the constness test is “are all my direct children literals?” — O(k) — rather than “is my whole subtree constant?” — O(subtree).

That is a real algorithmic improvement, and it depends entirely on the traversal order. The doc comment for the function states the contract:

This function only checks the direct children of the expression, not the entire subtree. It is designed to be used with bottom-up tree traversal, where children are simplified before parents.

The catch

  • The dummy batch is a fiction. It works because the expression’s children are all literals, so the batch is never read. But that is an invariant the caller must maintain, not something the type system checks. An expression that reads the batch produces a plausible-looking but wrong constant.
  • Folding changes error behaviour. If 1/0 is folded at plan time, the query fails at plan time rather than at run time. DataFusion’s Err(_) => keep original branch avoids this for expressions that error on the dummy batch but would be fine at run time — but an expression that successfully evaluates to an error value (like 1/0 producing an infinity, or a cast that overflows) is folded to that value.
  • Volatility is a declaration, not a fact. If a user-defined function declares itself Immutable but is not, the fold is wrong. That is documented behaviour and the user’s responsibility.
  • The regex check compiles the regex. The size limit protects against pathological programs, but the compilation itself still costs something. A query with many distinct regex literals pays for each compile at plan time.

The general lesson

The dummy-batch trick is a nice example of a pattern that recurs throughout DataFusion: use the runtime machinery at plan time, with a synthetic input, to derive a fact about the plan. The dummy batch is the synthetic input. The fact is “this expression is constant”. And the three guards are the difference between a clever trick and a bug.

The regex guard is the one I would point at. It is a check that exists purely to stop a user from making the planner do unbounded work, and it is implemented by actually attempting the expensive operation with a budget rather than by guessing at a proxy for the cost. That is the difference between a heuristic and a real limit.

DatafusionOptimizerConstant foldingPerformanceInternals