DataFusion folds constants at plan time: ConstEvaluator, the algebra rules, and the cast-unwrapping trick
How DataFusion's logical optimizer evaluates constant expressions once at plan time and rewrites CAST(col) = 5 into col = 5
TL;DR: DataFusion evaluates any expression that contains no column references once, at plan time, on a one-row dummy batch, and replaces it with a
Literal. The folding and the algebraic rules (x = true→x,NULL op y→NULL, …) run in a loop until neither produces a change.CAST(col AS bigint) = 5is rewritten tocol = 5— but only when the literal can be converted losslessly back into the column’s type. The whole thing is guarded by a volatility check (now()isStable,random()isVolatile) and a “planning budget” for regexes.
The problem: expressions that do the same work a million times
Write SELECT * FROM t WHERE CAST(id AS BIGINT) = 5. The naive evaluation plan is: read id as Int32, cast every row to Int64, compare against Int64(5). The cast is a real per-row Arrow kernel with real memory traffic, and — worse — the predicate now references a computed column, not a bare one, which most storage engines cannot use for row-group pruning or dictionary filtering. Parquet will happily evaluate CAST(id AS BIGINT) = 5 as a “filter” but it cannot skip row groups on it, because the row group’s id statistics are Int32.
The same problem shows up in a different costume with WHERE x > 10 AND lower(name) = 'bob': the lower(name) is evaluated per row, but it is a constant expression in the sense that it does not depend on the row’s identity — it depends only on a value that is the same for every row. DataFusion’s answer to this is not a special case for lower; it is a general machinery that evaluates any column-free expression at plan time.
Where this happens in the pipeline
The logical optimizer is a list of OptimizerRules applied in a fixed order, repeatedly, until the plan stops changing:
// datafusion/optimizer/src/optimizer.rs:283-318
let rules: Vec<Arc<dyn OptimizerRule + Sync + Send>> = vec![
Arc::new(RewriteSetComparison::new()),
Arc::new(OptimizeUnions::new()),
Arc::new(UnionsToFilter::new()),
Arc::new(SimplifyExpressions::new()), // <-- this article
Arc::new(ReplaceDistinctWithAggregate::new()),
...
];Optimizer::optimize (datafusion/optimizer/src/optimizer.rs:598-760) runs the whole list up to optimizer.max_passes times (default 3, datafusion/common/src/config.rs:1821), and stops early when a pass produces a plan signature it has already seen (LogicalPlanSignature). A rule declares how it wants to be driven via apply_order():
// datafusion/optimizer/src/optimizer.rs:263-270
pub enum ApplyOrder {
/// Apply the rule to the node before its inputs
TopDown,
/// Apply the rule to the node after its inputs
BottomUp,
}SimplifyExpressions is BottomUp (datafusion/optimizer/src/simplify_expressions/simplify_exprs.rs:57-60), which matters: bottom-up means children are simplified before their parents, so 1 + 2 is already 3 by the time the enclosing expression is inspected. That single ordering decision is what makes constant folding cheap — you never have to check “is this whole subtree constant?”, you only have to check “are my children literals?”.
The ConstEvaluator: plan-time evaluation on a dummy batch
The rule builds a SimplifyContext (schema + config options + query start time) and hands it to an ExprSimplifier:
// datafusion/optimizer/src/simplify_expressions/simplify_exprs.rs:98-115
let info = SimplifyContext::builder()
.with_schema(schema)
.with_config_options(config.options())
.with_query_execution_start_time(config.query_execution_start_time())
.build();
let simplifier = ExprSimplifier::new(info);The core of the rule is a fixpoint loop over three rewriters (datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs:195-241):
// datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs:213-232
loop {
let Transformed { data, transformed, .. } = expr
.rewrite(&mut const_evaluator)?
.transform_data(|expr| expr.rewrite(&mut simplifier))?
.transform_data(|expr| rewrite_with_guarantees_map(expr, &guarantees_map))?;
expr = data;
num_cycles += 1;
has_transformed = has_transformed || transformed;
if !transformed || num_cycles >= self.max_simplifier_cycles {
break;
}
}The comment in the source says it plainly: “Evaluating constants can enable new simplifications and simplifications can enable new constant evaluation.” 1 + 2 > 2 folds to 3 > 2 folds to true; x = 1 + 2 folds to x = 3 which may then match a IN list or a range rule.
How “is this subtree constant?” is decided
ConstEvaluator is a TreeNodeRewriter that keeps a stack of booleans, one per level of the expression tree:
// datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs:514-536
fn f_down(&mut self, expr: Expr) -> Result<Transformed<Expr>> {
// Default to being able to evaluate this node
self.can_evaluate.push(true);
// if this expr is not ok to evaluate, mark entire parent
// stack as not ok (as all parents have at least one child or
// descendant that can not be evaluated
if !Self::can_evaluate(&expr) {
let parent_iter = self.can_evaluate.iter_mut().rev();
for p in parent_iter {
if !*p {
// optimization: if we find an element on the
// stack already marked, know all elements above are also marked
break;
}
*p = false;
}
}
// NB: do not short circuit recursion even if we find a non
// evaluatable node (so we can fold other children, args to
// functions, etc.)
Ok(Transformed::no(expr))
}Two details are worth stealing. First, the stack is marked all the way up when a child is not evaluatable, so a parent of a non-constant child is never constant — but siblings are still visited so that f(a, 1+2) can fold the 1+2 argument. Second, the “already marked ⇒ everything above is marked” early break keeps the traversal O(depth) instead of O(depth²) in the pathological case.
The volatility gate is in can_evaluate (expr_simplifier.rs:618-628):
fn volatility_ok(volatility: Volatility) -> bool {
match volatility {
Volatility::Immutable => true,
// Values for functions such as now() are taken from ExecutionProps
Volatility::Stable => true,
Volatility::Volatile => false,
}
}Stable is allowed because DataFusion pins the value: now() is evaluated with the query execution start time taken out of OptimizerConfig::query_execution_start_time() (datafusion/optimizer/src/optimizer.rs:144-146), so every row in the query sees the same timestamp. Volatile (random(), uuid()) is refused, because folding random() > 0.5 into true would change the answer.
The actual evaluation
// datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs:709-742
pub(crate) fn evaluate_to_scalar(&mut self, expr: Expr) -> ConstSimplifyResult {
if let Expr::Literal(s, m) = expr {
return ConstSimplifyResult::NotSimplified(s, m);
}
if !should_evaluate_const_expr(&expr) {
return ConstSimplifyResult::Deferred(expr);
}
let phys_expr = match create_physical_expr(
&expr, &DUMMY_DF_SCHEMA, &self.execution_props, &PhysicalPlanningContext::default(),
) { ... };
let col_val = match phys_expr.evaluate(&DUMMY_BATCH) { ... };The trick is that the logical expression is compiled to a physical expression and evaluated against a fake one-row batch of a single Null column:
// datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs:582-592
static DUMMY_SCHEMA: LazyLock<Arc<Schema>> =
LazyLock::new(|| Arc::new(Schema::new(vec![Field::new(".", DataType::Null, true)])));
static DUMMY_DF_SCHEMA: LazyLock<DFSchema> =
LazyLock::new(|| DFSchema::try_from(Arc::clone(&*DUMMY_SCHEMA)).unwrap());
static DUMMY_BATCH: LazyLock<RecordBatch> = LazyLock::new(|| {
// Need a single "input" row to produce a single output row
let col = new_null_array(&DataType::Null, 1);
RecordBatch::try_new(DUMMY_SCHEMA.clone(), vec![col]).unwrap()
});The batch content is irrelevant — it exists only because PhysicalExpr::evaluate demands one. Because the expression has no column references, the null column is never read.
There is a physically identical mechanism on the physical side, simplify_const_expr_immediate (datafusion/physical-expr/src/simplifier/const_evaluator.rs:97-160), and its doc comment spells out the O(k) advantage of bottom-up traversal:
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 algebra rules
Folding is only half of it. The Simplifier rewriter (expr_simplifier.rs:828-2170) applies textbook boolean and null algebra. A representative selection:
value op NULL→NULLfor any operator whereop.returns_null_on_null()(expr_simplifier.rs:872-883).true = A→A;false = A→!A;null = A→NULL(expr_simplifier.rs:899-936).A = A→A IS NOT NULL OR NULL, or plaintruewhenAis non-nullable (expr_simplifier.rs:935-946). This one is subtle and correct: in SQL,NULL = NULLisNULL, nottrue.true OR A→trueeven if A is null (expr_simplifier.rs:982-990) — SQL’sORis not three-valued in the way people expect.- De Morgan:
NOT (A AND B)→NOT A OR NOT B.
The A = A rule is the sort of thing that makes the simplifier look magic in an EXPLAIN, and it is also the sort of thing that is wrong if you forget null semantics. Notice how the rule asks the schema: info.nullable(&left)?. It only emits true when the column is provably non-null.
The cast-unwrapping trick
Now the headline. CAST(id AS BIGINT) = 5 should become id = 5.
// datafusion/optimizer/src/simplify_expressions/unwrap_cast.rs:20-62 (doc comment)
// 1. `cast(left_expr as data_type) comparison_op literal_expr`
// 2. `literal_expr comparison_op cast(left_expr as data_type)`
// 3. `cast(literal_expr) IN (expr1, expr2, ...)`
// 4. `literal_expr IN (cast(expr1) , cast(expr2), ...)`
//
// If the expression matches one of the forms above, the rule will
// ensure the value of `literal` is in range(min, max) of the
// expr's data_type, and if the scalar is within range, the literal
// will be casted to the data type of expr on the other side, and the
// cast will be removed from the other side.
The rewrite itself is in unwrap_cast_in_comparison_for_binary (unwrap_cast.rs:67-115):
match (cast_expr, literal) {
(
Expr::TryCast(TryCast { expr, .. }) | Expr::Cast(Cast { expr, .. }),
Expr::Literal(lit_value, _),
) => {
let Ok(expr_type) = info.get_data_type(&expr) else { ... };
if let Some(value) = cast_literal_to_type_with_op(&lit_value, &expr_type, op) {
return Ok(Transformed::yes(Expr::BinaryExpr(BinaryExpr {
left: expr, op, right: Box::new(lit(value)),
})));
}
// if the lit_value can be casted to the type of internal_left_expr
// we need to unwrap the cast for cast/try_cast expr, and add cast to the literal
let Some(value) = try_cast_literal_to_type(&lit_value, &expr_type) else {
return internal_err!("Can't cast the literal expr {:?} to type {}", &lit_value, &expr_type);
};
Ok(Transformed::yes(Expr::BinaryExpr(BinaryExpr {
left: expr, op, right: Box::new(lit(value)),
})))
}The critical function is try_cast_literal_to_type (datafusion/expr-common/src/casts.rs:36-77), whose doc comment is the whole correctness argument in one paragraph:
Convert a literal
ScalarValuetotarget_type, preserving the exact value. ReturnsNoneif the value cannot be represented intarget_typeexactly.
So CAST(c1 AS BIGINT) < 16 becomes c1 < 16 (Int32 literal), but CAST(c1 AS BIGINT) < 99999999999 does not get rewritten, because 99999999999 is not representable in Int32 — the comparison would be wrong. The unit test at unwrap_cast.rs:270-273 states it exactly:
// the 99999999999 is not within the range of MAX(int32) and MIN(int32), we don't cast the lit(99999999999) to int32 type
let expr_lt = cast(col("c1"), DataType::Int64).lt(lit(99999999999i64));
assert_eq!(optimize_test(expr_lt.clone(), &schema), expr_lt);There is a second, sneakier path. CAST(name AS Utf8) = '123' is rewritten to name = 123 (an integer comparison) — which is a big deal, because it turns a string comparison into one that can use integer dictionary encoding. That path uses a round-trip check (unwrap_cast.rs:217-243):
(Operator::Eq | Operator::NotEq, ScalarValue::Utf8(Some(_)) | ...) => {
if matches!(target_type, Int8 | Int16 | Int32 | Int64 | UInt8 | ...) {
let casted = lit_value.cast_to(target_type).ok()?;
let round_tripped = casted.cast_to(&lit_value.data_type()).ok()?;
if lit_value != &round_tripped {
return None; // '0123' would not survive the round trip
}
Some(casted)
} else { None }
}'0123' casts to 123 and back to '123' ≠ '0123', so the rewrite is refused. '123' round-trips and the rewrite is allowed.
And there are deliberate exclusions. is_timestamp_precision_narrowing_cast and is_date_narrowing_cast (unwrap_cast.rs:130-137) block the rewrite entirely for temporal types, because CAST(ts AS DATE) = DATE '2024-01-01' means “any timestamp during that day” — rewriting it to ts = TIMESTAMP '2024-01-01 00:00:00' would only match midnight. datafusion/expr-common/src/casts.rs:91-105 explains this in detail.
Finally, the whole thing is gated on the operator: op.supports_propagation() and, for the mirrored form, op.swap().is_some() (expr_simplifier.rs:1996-2022). > cannot be swapped into < without also flipping the operand, and the rule only does the mirror when the operator set provides a defined swap().
Trade-offs and traps
Folding can fail at runtime but not at plan time. ConstEvaluator handles this with a Deferred state and by refusing to fold short-circuiting constructs on error. The comment at expr_simplifier.rs:543-547 is explicit: “Certain expressions such as CASE and COALESCE are short-circuiting and may not evaluate all their sub expressions. Thus, if any error is encountered during simplification, return the original so that normal evaluation can occur.” The one exception is a CAST of a literal, which is allowed to fail the query at plan time for a better error message (expr_simplifier.rs:556-566).
Some constants are dangerous to compute early. should_evaluate_const_expr (expr_simplifier.rs:774-800) applies a planning budget to regexes: a pattern that compiles to more than REGEX_PLANNING_SIZE_LIMIT_BYTES is not compiled at plan time, because a pathological regex would make the optimizer slow, which is a much worse failure mode than a slow query.
Folding is not free. Every rule in the list runs on every query. The optimizer’s own comment (optimizer.rs:285-290) says adding a rule “is expensive as it will be applied to all queries, and will likely increase the optimization time. Please extend existing rules when possible, rather than adding a new rule.” This is why the const-folding logic lives inside SimplifyExpressions rather than in a rule of its own.
Struct casts are excluded. can_evaluate refuses to fold a struct cast whose field counts differ or whose field names don’t overlap (expr_simplifier.rs:656-683), because the simplifier evaluates against a one-row batch and a zero-row struct literal would produce a dimension mismatch.
The payoff. Once CAST(id AS BIGINT) = 5 is id = 5, the next rule in the list can push it into the table scan as a bare-column predicate, and the Parquet reader can use the row group’s id statistics to skip entire row groups. The cast unwrapping is not about saving a cast kernel; it is about making the predicate pushable. That is the real trick, and it is why the rule lives in the simplifier rather than in the pushdown rule.