Skip to content

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

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

adriangb wants to merge 5 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 80.87056% with 167 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.89%. Comparing base (484183d) to head (2b967a0).
⚠️ Report is 162 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/core/src/physical_planner.rs 82.71% 7 Missing and 7 partials ⚠️
datafusion/expr/src/logical_plan/plan.rs 58.06% 11 Missing and 2 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.89%   82.89%   -0.01%     
==========================================
  Files        1154     1155       +1     
  Lines      457630   458251     +621     
  Branches   457630   458251     +621     
==========================================
+ Hits       379370   379855     +485     
- Misses      55256    55288      +32     
- Partials    23004    23108     +104     

☔ 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 af752e5d5620b13e759e1713c6c0b72c43b4fb9b
    Building datafusion v55.1.0 (current)
       Built [  65.225s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.036s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  17.996s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.037s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.634s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  85.378s] datafusion
    Building datafusion-common v55.1.0 (current)
       Built [  19.411s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.077s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [   1.225s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.080s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.853s] 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:1613
  field OptimizerOptions.automatic_cte_materialization_keeps_volatile in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:1613

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  22.642s] datafusion-common
    Building datafusion-expr v55.1.0 (current)
       Built [  16.171s] (current)
     Parsing datafusion-expr v55.1.0 (current)
      Parsed [   0.109s] (current)
    Building datafusion-expr v55.1.0 (baseline)
       Built [   7.644s] (baseline)
     Parsing datafusion-expr v55.1.0 (baseline)
      Parsed [   0.118s] (baseline)
    Checking datafusion-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.641s] 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 MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:89
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:89
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:89
  field MaterializedCte.automatic in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:89

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  26.591s] datafusion-expr
    Building datafusion-optimizer v55.1.0 (current)
       Built [  17.669s] (current)
     Parsing datafusion-optimizer v55.1.0 (current)
      Parsed [   0.037s] (current)
    Building datafusion-optimizer v55.1.0 (baseline)
       Built [   7.999s] (baseline)
     Parsing datafusion-optimizer v55.1.0 (baseline)
      Parsed [   0.037s] (baseline)
    Checking datafusion-optimizer v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.187s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  26.569s] datafusion-optimizer
    Building datafusion-sql v55.1.0 (current)
       Built [  16.902s] (current)
     Parsing datafusion-sql v55.1.0 (current)
      Parsed [   0.034s] (current)
    Building datafusion-sql v55.1.0 (baseline)
       Built [  13.115s] (baseline)
     Parsing datafusion-sql v55.1.0 (baseline)
      Parsed [   0.036s] (baseline)
    Checking datafusion-sql v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.252s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  31.452s] datafusion-sql

@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 2 times, most recently from 26d5165 to 644524b Compare October 16, 2026 16:57
@adriangb
adriangb force-pushed the mcte-auto-materialize branch 3 times, most recently from 4f0729c to 2b967a0 Compare October 17, 2026 21:58
@adriangb
adriangb force-pushed the mcte-auto-materialize branch from 2b967a0 to b6991f1 Compare October 28, 2026 00:59
@github-actions github-actions Bot removed substrait Changes to the substrait crate proto Related to proto crate labels Oct 28, 2026
@adriangb
adriangb marked this pull request as ready for review October 28, 2026 01:03
Add `MaterializedCte::automatic`, which tells an explicit
`AS MATERIALIZED` node apart from one that the planner may create without
a directive from the user. The optimizer will be free to inline an
automatic node, and never inlines an explicit one. The flag defaults to
`false`, is kept by tree node rewrites, and is shown in EXPLAIN as
`automatic=true`.

Add `MaterializedCte::inline`, which returns the continuation with a copy
of the body in place of each scan of the CTE, also in subquery
expressions. Each copy keeps the schema of the scan it replaces (with a
projection when the qualified names differ), so the parents of the scans
stay valid. A nested occurrence of the same CTE keeps its own scans.

Nothing creates an automatic node yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adriangb and others added 4 commits October 27, 2026 23:39
Add `datafusion.optimizer.enable_automatic_cte_materialization` and its
companion `datafusion.optimizer.automatic_cte_materialization_keeps_volatile`.
Both are off by default, so each query plans as before.

When the option is on, the SQL planner plans each non-recursive CTE that
has no `MATERIALIZED` or `NOT MATERIALIZED` directive as a
`MaterializedCte` with `automatic = true`. `NOT MATERIALIZED`, a body with
an outer reference, and the CTEs of a `WITH RECURSIVE` block are still
inlined. With `SELECT INTO`, the DDL node must stay the root of the plan,
so an automatic CTE is inlined there instead of returning the "not
implemented" error that an explicit one returns.

No optimizer rule inlines automatic nodes yet, so with the option on every
such CTE is materialized; the next commits add that decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add `cte_auto_materialize.slt`, which runs CTE queries with
`datafusion.optimizer.enable_automatic_cte_materialization` on: CTEs with
zero, one and more references, volatile bodies, bodies with outer
references, simple and complex bodies, LIMITs above the references,
directives, nested and recursive CTEs, `SELECT INTO`, and unbounded
bodies.

The expected output is recorded at this commit, where no rule inlines an
automatic CTE yet: every CTE without a directive is materialized, and a
query whose CTE body is unbounded fails. The following commits update the
expectations, so their diffs show how the plans change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the optimizer rule `InlineAutomaticCtes`, which runs first in the
default rule list and decides which automatic materialized CTEs stay
materialized. It replaces an automatic node with `MaterializedCte::inline`
when:

- the CTE has zero or one reference,
- its body is volatile (unless
  `automatic_cte_materialization_keeps_volatile` is on) or has an outer
  reference,
- its body is only projections and filters on one table, so computing it
  again is cheap and inlining keeps filter and projection pushdown, or
- a `LIMIT` with a fetch is above all references in the chain of one-input
  nodes from the root of the query, so the query may read only a few rows.

All other automatic CTEs stay materialized and are computed one time.
Explicit `AS MATERIALIZED` CTEs are never inlined.

The diff of `cte_auto_materialize.slt` shows which CTEs are now inlined.
Update the optimizer rule reference and `explain.slt` for the new rule.

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.

This commit completes the feature: the physical planner inlines an automatic CTE that has an unbounded body, because such a body cannot be buffered and the query worked before.
Planning such a body returns an internal `UnboundedAutomaticCte` error, and `create_initial_plan` catches it, inlines that CTE and plans again.
An explicit `AS MATERIALIZED` CTE with an unbounded body still returns a planning error.
The diff of `cte_auto_materialize.slt` shows the unbounded cases that now plan and run.

Closes apache#26237.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adriangb
adriangb force-pushed the mcte-auto-materialize branch from b6991f1 to 063402b Compare October 28, 2026 05:30
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 sql SQL Planner sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide automatically when to materialize a CTE

2 participants