Skip to content

feat: decide automatically when to materialize a CTE - #26276

Draft
adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:mcte-auto-materialize
Draft

adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:mcte-auto-materialize

Conversation

@adriangb

@adriangb adriangb commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Stacked on #26269. Only the last commit is new.

Rationale for this change

A CTE that has no directive is inlined at each reference, so its body runs one time for each reference:

set datafusion.optimizer.enable_automatic_cte_materialization = true;

EXPLAIN WITH c AS (SELECT k, sum(v) AS s FROM t GROUP BY k)
SELECT a.k FROM c a JOIN c b ON a.s = b.s;

With the option off, which is the default, t is aggregated two times. With the option on, the body is computed one time:

MaterializedCte: name=c, automatic=true
  SubqueryAlias: c
    Projection: t.k, sum(t.v) AS s
      Aggregate: groupBy=[[t.k]], aggr=[[sum(CAST(t.v AS Int64))]]
        TableScan: t projection=[k, v]
  Projection: a.k
    Inner Join: a.s = b.s
      SubqueryAlias: a
        MaterializedCteScan: name=c
      SubqueryAlias: b
        Projection: c.s
          MaterializedCteScan: name=c

This works in each SQL dialect. A CTE with one reference, or with a cheap body, plans exactly as before.

What changes are included in this PR?

  • New option datafusion.optimizer.enable_automatic_cte_materialization, default false. With the default, each query plans as before.
  • New option datafusion.optimizer.automatic_cte_materialization_keeps_volatile, default false. See rule 2 below.
  • MaterializedCte has the new field automatic. When the option is on, the SQL planner plans each non-recursive CTE that has no directive as an automatic MaterializedCte.
  • New optimizer rule InlineAutomaticCtes, the first rule of the optimizer. It replaces an automatic node with a copy of the body in place of each scan when one of these conditions is true:
    1. The CTE has zero or one reference.
    2. The body has a volatile expression, and datafusion.optimizer.automatic_cte_materialization_keeps_volatile is false (the default). Each reference then evaluates the expression again, as with the main option off (Avoid inlining non deterministic CTE #10337). With the second option true, the CTE stays materialized and each reference reads the same rows. This is what PostgreSQL and DuckDB do.
    3. The body has an outer reference.
    4. The body is cheap: only projections, filters and aliases on one table scan, materialized CTE scan, VALUES list or empty relation, without a subquery expression.
  • In all other cases the CTE stays materialized. The rule does not use statistics or a cost model.
  • AS MATERIALIZED and AS NOT MATERIALIZED override the rule.
  • A CTE in a WITH RECURSIVE block and a CTE with a correlated body are inlined by the SQL planner, as before. SELECT INTO with an automatic CTE inlines the CTE.
  • An automatic CTE with an unbounded body cannot be buffered. The physical planner inlines it and plans the query again, so a query that worked before still works. This uses a private marker error that create_initial_plan catches; a different mechanism is possible.

Known limits:

  • For an unbounded body, EXPLAIN shows the MaterializedCte node in the logical plan and the inlined query in the physical plan, and the inlined copies do not get logical filter pushdown.
  • With the option on, a plan that is not optimized has MaterializedCte nodes for each CTE. The unparser and the Substrait producer return a "not implemented" error for these nodes.
  • "Cheap" does not look at the cost of projection expressions. No benchmarks were run.

The rule also inlines an automatic CTE when a LIMIT with a fetch is above all of its references. The rule finds such a limit by following the nodes with one input from the root of the query, and it stops at a node with a subquery expression. A materialized body is computed completely before any reference reads a row, so a limit above the references cannot stop it early. Inlined copies stop when the limit has its rows. This follows the ContainsLimit check of DuckDB, with one difference: a sort with a fetch (TopK) does not count, because TopK reads all of its input, and inlining would then compute the body more than one time. A limit above only some references, for example above one side of a join, keeps the CTE materialized. A limit inside the CTE body also keeps it materialized.

Comparison with PostgreSQL and DuckDB. PostgreSQL inlines a CTE with no directive only when it has one reference and no volatile functions, and it does not use cost (subselect.c). DuckDB materializes by default since v1.4.0. It keeps a CTE with a volatile body or an aggregate body materialized, and it inlines a cheap body (cte_inlining.cpp). The rules of this PR are closest to DuckDB's. Two things differ: a volatile body is inlined unless automatic_cte_materialization_keeps_volatile is set, and a "cheap" body also includes filters and projections on one table, because DataFusion pushes those into the scan.

Are these changes tested?

Yes. New file cte_auto_materialize.slt: two references to an aggregate body, one reference, no reference, cheap body, volatile body (with each value of automatic_cte_materialization_keeps_volatile), scan in a subquery expression, CTE read by another CTE, correlated body, recursive CTE, SELECT INTO, the two directives, the MySQL dialect, an unbounded body, and a LIMIT above all references, above one side of a join, and none. Unit tests for InlineAutomaticCtes and for MaterializedCte::inline. The full sqllogictest suite also passes with the default of the option set to true, except for the tests that show the default value.

Are there any user-facing changes?

Yes. There are two new configuration options, which are off by default, and a new optimizer rule. MaterializedCte has the new public field automatic and the new methods with_automatic, is_automatic and inline. The upgrade guide for 56.0.0 and the configuration documentation are updated.

@github-actions github-actions Bot added documentation Improvements or additions to documentation sql SQL Planner logical-expr Logical plan and expressions optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) substrait Changes to the substrait crate common Related to common crate proto Related to proto crate physical-plan Changes to the physical-plan crate labels Oct 13, 2026
@codecov-commenter

codecov-commenter commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.27854% with 164 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.89%. Comparing base (5009f42) to head (4f0729c).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/optimizer/src/inline_automatic_ctes.rs 80.96% 3 Missing and 72 partials ⚠️
...tafusion/expr/src/logical_plan/materialized_cte.rs 83.33% 22 Missing and 16 partials ⚠️
datafusion/expr/src/logical_plan/plan.rs 58.06% 11 Missing and 2 partials ⚠️
datafusion/core/src/physical_planner.rs 86.90% 6 Missing and 5 partials ⚠️
datafusion/expr/src/logical_plan/display.rs 0.00% 8 Missing ⚠️
datafusion/optimizer/src/analyzer/type_coercion.rs 83.72% 4 Missing and 3 partials ⚠️
...ion/substrait/src/logical_plan/producer/rel/mod.rs 0.00% 4 Missing ⚠️
datafusion/optimizer/src/decorrelate.rs 76.92% 1 Missing and 2 partials ⚠️
datafusion/optimizer/src/optimizer.rs 66.66% 0 Missing and 2 partials ⚠️
datafusion/expr/src/logical_plan/tree_node.rs 94.44% 0 Missing and 1 partial ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26276      +/-   ##
==========================================
+ Coverage   82.87%   82.89%   +0.01%     
==========================================
  Files        1153     1155       +2     
  Lines      457101   458241    +1140     
  Branches   457101   458241    +1140     
==========================================
+ Hits       378818   379838    +1020     
- Misses      55268    55292      +24     
- Partials    23015    23111      +96     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v55.1.0 (current)
       Built [  53.775s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.033s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  53.319s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.034s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.604s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 109.622s] datafusion
    Building datafusion-common v55.1.0 (current)
       Built [  31.779s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.066s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [  32.068s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.070s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.832s] 229 checks: 228 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field OptimizerOptions.enable_automatic_cte_materialization in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:1643
  field OptimizerOptions.automatic_cte_materialization_keeps_volatile in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:1643

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  66.038s] datafusion-common
    Building datafusion-expr v55.1.0 (current)
       Built [  29.431s] (current)
     Parsing datafusion-expr v55.1.0 (current)
      Parsed [   0.088s] (current)
    Building datafusion-expr v55.1.0 (baseline)
       Built [  29.433s] (baseline)
     Parsing datafusion-expr v55.1.0 (baseline)
      Parsed [   0.091s] (baseline)
    Checking datafusion-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.377s] 229 checks: 227 pass, 2 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:102
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:102
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:102
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:102

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/enum_variant_added.ron

Failed in:
  variant LogicalPlan:MaterializedCte in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/plan.rs:309
  variant LogicalPlan:MaterializedCte in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/plan.rs:309

     Summary semver requires new major version: 2 major and 0 minor checks failed
    Finished [  62.111s] datafusion-expr
    Building datafusion-optimizer v55.1.0 (current)
       Built [  24.009s] (current)
     Parsing datafusion-optimizer v55.1.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion-optimizer v55.1.0 (baseline)
       Built [  24.068s] (baseline)
     Parsing datafusion-optimizer v55.1.0 (baseline)
      Parsed [   0.033s] (baseline)
    Checking datafusion-optimizer v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.171s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  49.259s] datafusion-optimizer
    Building datafusion-proto v55.1.0 (current)
       Built [  49.014s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.013s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  48.803s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.013s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.131s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  99.302s] datafusion-proto
    Building datafusion-sql v55.1.0 (current)
       Built [  39.716s] (current)
     Parsing datafusion-sql v55.1.0 (current)
      Parsed [   0.030s] (current)
    Building datafusion-sql v55.1.0 (baseline)
       Built [  39.748s] (baseline)
     Parsing datafusion-sql v55.1.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion-sql v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.246s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  81.110s] datafusion-sql
    Building datafusion-sqllogictest v55.1.0 (current)
       Built [  90.988s] (current)
     Parsing datafusion-sqllogictest v55.1.0 (current)
      Parsed [   0.015s] (current)
    Building datafusion-sqllogictest v55.1.0 (baseline)
       Built [  90.956s] (baseline)
     Parsing datafusion-sqllogictest v55.1.0 (baseline)
      Parsed [   0.016s] (baseline)
    Checking datafusion-sqllogictest v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.103s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 184.763s] datafusion-sqllogictest
    Building datafusion-substrait v55.1.0 (current)
       Built [ 305.896s] (current)
     Parsing datafusion-substrait v55.1.0 (current)
      Parsed [   0.013s] (current)
    Building datafusion-substrait v55.1.0 (baseline)
       Built [ 309.857s] (baseline)
     Parsing datafusion-substrait v55.1.0 (baseline)
      Parsed [   0.012s] (baseline)
    Checking datafusion-substrait v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.245s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 617.973s] datafusion-substrait

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Oct 13, 2026
@adriangb
adriangb force-pushed the mcte-auto-materialize branch 2 times, most recently from 2490f15 to 2de017a Compare October 16, 2026 03:46
@github-actions github-actions Bot removed the physical-plan Changes to the physical-plan crate label Oct 16, 2026
@adriangb
adriangb force-pushed the mcte-auto-materialize branch from 2de017a to 26d5165 Compare October 16, 2026 14:58
Replace the `MaterializedCte` and `MaterializedCteScan` extension nodes with the variants `LogicalPlan::MaterializedCte` and `LogicalPlan::MaterializedCteScan`.
Each rule now states what it does with the nodes in its `match`, and the downcasts are removed from `PushDownFilter`, `PullUpCorrelatedExpr`, `head_output_expr` and the physical planner.
`MaterializedCte::try_new` checks that the scans have the schema of the body, and `MaterializedCteScan::find_in` finds the scans of one CTE, also in subquery expressions.
The unparser, the Substrait producer and the protobuf serializer return a "not implemented" error for the two variants.
Plans and results do not change.

Closes apache#26238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the option `datafusion.optimizer.enable_automatic_cte_materialization`, which is off by default.
With the default, each query plans as before.

When the option is on, the SQL planner plans each non-recursive CTE that has no directive as a `MaterializedCte` with `automatic = true`, in each dialect.
The new optimizer rule `InlineAutomaticCtes` replaces an automatic node with `MaterializedCte::inline` when the CTE has zero or one reference, or when its body is volatile, has an outer reference, or is only projections and filters on one table, or when a `LIMIT` with a fetch is above all references in the chain of one-input nodes from the root of the query.
All other automatic CTEs stay materialized and are computed one time.
`AS MATERIALIZED` and `AS NOT MATERIALIZED` override the rule.

The physical planner inlines an automatic CTE that has an unbounded body, because such a body cannot be buffered and the query worked before.

Closes apache#26237.
@adriangb
adriangb force-pushed the mcte-auto-materialize branch from 8575777 to 4f0729c Compare October 16, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions optimizer Optimizer rules proto Related to proto crate sql SQL Planner sqllogictest SQL Logic Tests (.slt) substrait Changes to the substrait crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide automatically when to materialize a CTE

2 participants