Skip to content

feat: prune unused columns from the body of a materialized CTE - #26274

Draft
adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:mcte-column-pruning
Draft

adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:mcte-column-pruning

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

The body of a materialized CTE kept all its columns, also when no reference read them.

CREATE TABLE wide(a INT, b INT, c INT, d INT, e INT) AS VALUES (1, 10, 100, 1000, 10000);

EXPLAIN WITH w AS MATERIALIZED (SELECT * FROM wide)
SELECT a FROM w
UNION ALL
SELECT b FROM w;

Before, all five columns were read and buffered:

MaterializedCte: name=w
  SubqueryAlias: w
    TableScan: wide projection=[a, b, c, d, e]
  Union
    Projection: w.a
      MaterializedCteScan: name=w
    Projection: w.b AS a
      MaterializedCteScan: name=w

After, only a and b are read and buffered:

MaterializedCte: name=w
  SubqueryAlias: w
    TableScan: wide projection=[a, b]
  Union
    MaterializedCteScan: name=w projection=[a]
    Projection: w.b AS a
      MaterializedCteScan: name=w projection=[b]

What changes are included in this PR?

  • MaterializedCteScan has a new field projection: Option<Vec<usize>>: the indexes of the columns of the CTE body that the scan outputs.
  • OptimizeProjections narrows each MaterializedCteScan to the columns that its parent needs, as it does for a TableScan.
  • At a MaterializedCte, OptimizeProjections optimizes the continuation first. It then computes the union of the columns of all the scans of the CTE, projects the body to this union, and gives each scan the new positions of its columns. Scans in subquery expressions and in the bodies of other CTEs are counted.
  • MaterializedCteScanExec applies the projection to the shared buffered batches.
  • The physical planner checks that each scan agrees with the physical schema of its body when it binds the scan, and returns an internal error if it does not.

The projection in the scan makes the intermediate state (a narrow scan and a wide body) a valid plan, so no separate rule is necessary.

Notes:

  • A reference that reads no column (SELECT count(*) FROM c) gets projection=[]. If no reference reads a column, the body keeps its first column.
  • The projection is put above the body. A volatile column can be removed, but the number of rows does not change. Columns below a DISTINCT or a recursive query are not removed.
  • A scan in a correlated subquery that is not decorrelated is not narrowed. The CTE then keeps all its columns.
  • The pgjson display does not show the projection.

Comparison with PostgreSQL and DuckDB. This is what DuckDB does: RemoveUnusedColumns prunes the body to the columns that its references read (remove_unused_columns.cpp). PostgreSQL plans a CTE for full retrieval and does not prune (subselect.c).

Are these changes tested?

Yes. cte_materialized.slt: EXPLAIN and result checks for the example above, a column that only a filter reads, count(*), a CTE that another CTE reads, references in subqueries, a volatile body, a DISTINCT body, aliased columns, a self join, a recursive body and a CTE that nothing reads. Unit tests for the scan projection, nested CTEs, idempotence and the zero-column case.

Are there any user-facing changes?

EXPLAIN shows projection=[...] on MaterializedCteScan and MaterializedCteScanExec when the scan does not read all the columns. MaterializedCteScan has the new public field projection. The upgrade guide for 56.0.0 says this.

@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 proto Related to proto crate physical-plan Changes to the physical-plan crate labels Oct 13, 2026
@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 [  60.103s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  54.888s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.033s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.628s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 117.632s] datafusion
    Building datafusion-expr v55.1.0 (current)
       Built [  29.499s] (current)
     Parsing datafusion-expr v55.1.0 (current)
      Parsed [   0.091s] (current)
    Building datafusion-expr v55.1.0 (baseline)
       Built [  29.359s] (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.332s] 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 MaterializedCteScan.projection in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:217
  field MaterializedCteScan.projection in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:217
  field MaterializedCteScan.projection in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:217
  field MaterializedCteScan.projection in /home/runner/work/datafusion/datafusion/datafusion/expr/src/logical_plan/materialized_cte.rs:217

--- 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 [  61.638s] datafusion-expr
    Building datafusion-optimizer v55.1.0 (current)
       Built [  24.535s] (current)
     Parsing datafusion-optimizer v55.1.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion-optimizer v55.1.0 (baseline)
       Built [  24.497s] (baseline)
     Parsing datafusion-optimizer v55.1.0 (baseline)
      Parsed [   0.035s] (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 [  50.181s] datafusion-optimizer
    Building datafusion-physical-plan v55.1.0 (current)
       Built [  35.211s] (current)
     Parsing datafusion-physical-plan v55.1.0 (current)
      Parsed [   0.193s] (current)
    Building datafusion-physical-plan v55.1.0 (baseline)
       Built [  34.719s] (baseline)
     Parsing datafusion-physical-plan v55.1.0 (baseline)
      Parsed [   0.194s] (baseline)
    Checking datafusion-physical-plan v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.657s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  72.414s] datafusion-physical-plan
    Building datafusion-proto v55.1.0 (current)
       Built [  51.479s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.013s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  51.479s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.014s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.126s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 104.796s] datafusion-proto
    Building datafusion-sql v55.1.0 (current)
       Built [  42.052s] (current)
     Parsing datafusion-sql v55.1.0 (current)
      Parsed [   0.033s] (current)
    Building datafusion-sql v55.1.0 (baseline)
       Built [  41.744s] (baseline)
     Parsing datafusion-sql v55.1.0 (baseline)
      Parsed [   0.034s] (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 [  85.356s] datafusion-sql
    Building datafusion-sqllogictest v55.1.0 (current)
       Built [  93.758s] (current)
     Parsing datafusion-sqllogictest v55.1.0 (current)
      Parsed [   0.016s] (current)
    Building datafusion-sqllogictest v55.1.0 (baseline)
       Built [  92.517s] (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.100s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 189.291s] datafusion-sqllogictest
    Building datafusion-substrait v55.1.0 (current)
       Built [ 310.312s] (current)
     Parsing datafusion-substrait v55.1.0 (current)
      Parsed [   0.011s] (current)
    Building datafusion-substrait v55.1.0 (baseline)
       Built [ 316.368s] (baseline)
     Parsing datafusion-substrait v55.1.0 (baseline)
      Parsed [   0.015s] (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 [ 628.890s] datafusion-substrait

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Oct 13, 2026
@codecov-commenter

codecov-commenter commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.50209% with 133 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.88%. Comparing base (5009f42) to head (8acc60b).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...tafusion/expr/src/logical_plan/materialized_cte.rs 83.50% 20 Missing and 28 partials ⚠️
...tafusion/optimizer/src/optimize_projections/mod.rs 78.35% 6 Missing and 23 partials ⚠️
datafusion/physical-plan/src/materialized_cte.rs 85.41% 4 Missing and 10 partials ⚠️
datafusion/expr/src/logical_plan/plan.rs 65.78% 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/core/src/physical_planner.rs 93.33% 1 Missing and 2 partials ⚠️
datafusion/optimizer/src/decorrelate.rs 76.92% 1 Missing and 2 partials ⚠️
datafusion/optimizer/src/optimizer.rs 60.00% 0 Missing and 2 partials ⚠️
... and 2 more
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #26274    +/-   ##
========================================
  Coverage   82.87%   82.88%            
========================================
  Files        1153     1153            
  Lines      457101   457958   +857     
  Branches   457101   457958   +857     
========================================
+ Hits       378818   379556   +738     
- Misses      55268    55320    +52     
- Partials    23015    23082    +67     

☔ 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.

@adriangb
adriangb force-pushed the mcte-column-pruning branch 3 times, most recently from e93f908 to 00200fa Compare October 16, 2026 14:58
adriangb and others added 2 commits October 16, 2026 11:51
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>
The body of a `MaterializedCte` kept all its columns, because each `MaterializedCteScan` was a leaf with the full schema of the body.

A `MaterializedCteScan` now has a `projection`: the indexes of the columns of the body that it outputs.
`OptimizeProjections` narrows each scan to the columns that its parent needs, as it does for a `TableScan`.
At the `MaterializedCte` node it optimizes the continuation first, computes the union of the columns of all the scans of the CTE (also in subquery expressions and in the bodies of other CTEs), projects the body to this union, and gives each scan the new positions of its columns.
`MaterializedCteScanExec` applies the projection to the shared buffered batches.
The physical planner checks that each scan agrees with the schema of its body when it binds the scan.

Closes apache#26240.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions optimizer Optimizer rules physical-plan Changes to the physical-plan crate 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.

Prune unused columns from the body of a materialized CTE

2 participants