Skip to content

feat: give logical plan columns an identity (ColumnId) - #26842

Draft
cetra3 wants to merge 9 commits into
apache:mainfrom
pydantic:column-identity
Draft

cetra3 wants to merge 9 commits into
apache:mainfrom
pydantic:column-identity

Conversation

@cetra3

@cetra3 cetra3 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Note

This is a draft to discuss a direction, not a PR to merge as is. It shows what giving logical plan columns an identity looks like end to end, and which bugs it fixes. It replaces the earlier version of this draft, which put the ids on implicit aliases. The commits are nine stages, so each can be read on its own (see "What changes are included").

The core change

A column gets a ColumnId where it is defined, and references read it by that id. The name becomes a label.

// datafusion/common/src/column.rs
pub struct ColumnId(NonZeroU64);

pub struct Column {
    pub relation: Option<TableReference>,
    pub name: String,
    pub spans: Spans,
    /// The identity of the column this refers to
    pub id: Option<ColumnId>, // new
}
// datafusion/expr/src/expr.rs
/// An output of a Projection, Window, DistinctOn or Aggregate.
/// There is no way to build these nodes without ids.
pub struct NamedExpr {
    pub expr: Expr,
    pub id: ColumnId,
    // ...
}

Ids come from a ColumnIdGenerator per session. Columns that a node defines without an expression (union and recursive query outputs, unnested columns, the join mark column) keep their ids in the node's schema. TableScan keeps the ids of its source columns in a shared Arc<[ColumnId]>, so rebuilding a scan doesn't allocate per column.

Which issue does this PR close?

It closes no issues as a draft. It is related to the leaf expression and filter pushdown epic, #25459, and to the older discussions about column identity (#8379, #12723, #1468).

Bugs fixed

Each row was run on main at 888879d01c and on this branch. All are open except #26619.

Wrong results

Issue On main With this PR
#26234 wrong rows in all four forms (EXISTS, NOT EXISTS, IN, scalar) 1; 2; 1; (1,1),(2,0)
#26691 wrong rows: (c,-9),(b,-10) (a,-2),(d,-3)
#26692 Q1 wrong rows (1); Q2 and Q3 No field named ... 1,1; 1; 1

Errors on valid queries

Issue On main With this PR
#26621 No field named t.a 1 2 4; 1 2 x
#26573 No field named x.a (1,1),(1,2)
#25837 No field named l.id (1,10),(2,20)
#26033 No field named __correlated_sq_1.y 1, 2 (both queries)
#26619 (closed) the HAVING form is fixed on main; the projection form still fails with No field named __correlated_sq_1.c0 both correct
#26462 qualified field name e.email and unqualified field name email which would be ambiguous b@x, a@y
#26221 duplicate unqualified field name, or Projections/Windows require unique expression names (5 forms) correct
#3353 duplicate unqualified field name "approx_distinct(t.c)" 99 99
#6543 Projections require unique expression names two c1 columns
#4478 Projections require unique expression names 1 1
#13558 Projections require unique expression names 1, 3, 5
#13476 Projections require unique expression names (SELECT ts, id, *) correct
#14147 DataFrame join of two unnamed frames: duplicate qualified field "?table?".id1 joins
#21066 unparsed SQL fails to plan: __common_expr_1 is a reserved DataFusion column name plans, same rows
#21727 No field named t0.g (a column two levels up) 1
#15558 No field named e1.dept_id (a column two levels up) (c,60),(e,30)

Other

Issue On main With this PR
#26620 debug build panic: all window expressions shall have the same sort keys correct
#25447 the same get_field is extracted and computed twice computed once

#21727 and #15558 use targeted rewrites that only cover some shapes; see "Decorrelation: deferred work". #26622 was on the earlier list, but it now gives the same output on main, so it is not counted here.

Rationale for this change

The optimizer identifies a column by its qualifier and name. Two different columns can have the same name: a column and a cast of it, two scans of the same table, the outputs of two references to a CTE, or two aggregates that differ only by a cast. When a rule moves or rebuilds a node, it finds the columns it needs by name, and with such plans it finds the wrong one, or none. Users see wrong results, No field named errors, duplicate name errors and panics.

Each of these bugs has been fixed, or is being fixed, in the rule that hit it. The cause is shared, so the class keeps coming back. Spark (ExprId), DuckDB (ColumnBinding), Trino (symbols) and CockroachDB (ColumnID) all give each column an identity independent of its name.

What changes are included in this PR?

179 files, +18,951/−4,450. Each commit is one stage, and each builds and passes on its own (see the testing section for the exception).

Stage What it does Files Lines
1 ColumnId, optional ids on Column and DFSchema, the generator, a report-only verifier. Nothing allocates ids, so nothing changes. 27 +1,713/−215
2 Leaves allocate ids, references bind to them, plans blended together are renumbered. Names still decide identity. 20 +2,000/−177
3 Every definition has an id: NamedExpr, node-defined columns, grouping sets flattened out of Aggregate.group_expr. 70 +4,810/−1,419
4 Optimizer and analyzer rules find columns by id and keep ids. 38 +2,277/−1,245
5 Ids through protobuf, Substrait, extension nodes (opt-in) and MERGE INTO. 47 +2,526/−282
6 The switch: ids are identity, names are labels. Most of the bug fixes land here. 98 +5,101/−2,632
7 Decorrelation and remaining id fixes. 22 +723/−78
8 Planning overhead on wide schemas. 12 +287/−85
9 Outer references two levels up; removes the verifier's allowlist. 10 +1,318/−121

Stages 1 and 2 are the smallest, but they fix nothing by themselves: the benefit starts at stage 6, because names decide identity until then.

What the switch changes:

  • Names come from definitions. They are restored at the root and for write inputs when optimization ends, so rules no longer need NamePreserver or alias_if_changed.
  • Equality of columns, schemas and plans includes ids. is_equivalent_up_to_column_ids and Expr::without_column_ids compare without them.
  • A column with an id is looked up by id only. References without an id, such as hand-built expressions, still resolve by name.
  • Outputs may share a name when their ids differ. A lookup by that name is ambiguous, as in Postgres.
  • __common_expr_N and __datafusion_extracted_N are gone. Common subexpressions are named after their expressions, and extraction is a typed mark on NamedExpr.

Decorrelation: deferred work

Ids fix decorrelation bugs that are about binding: #26234, #26033, #26692, and the projection form of #26619. They do not make decorrelation general. That is #5492, and this PR leaves it open.

Stage 9 is a stopgap. On #21727, the request was not to extend the existing subquery rewrites because of the risk of more wrong results. Stage 9 does add to them, so it is the last commit, its rewrites are in their own module (decorrelate_nested.rs), and they are documented as going away with #5492. Both match columns by id, and apply only where they are exact:

Stage 9 can be dropped. #21727 and #15558 would go back to failing, and the verifier's allowlist would return. It also carries PushDownFilter fixes that would need to be kept.

Two-level shapes that still fail. These stop with Physical plan does not support logical expression Exists/InSubquery/ScalarSubquery, and column_identity.slt tests that none of them returns rows:

  • NOT EXISTS or NOT IN at the nested level
  • EXISTS under OR
  • an aggregate, LIMIT, DISTINCT or window in the middle subquery
  • a scalar middle subquery with no equality to substitute through
  • an equality under OR, on floats, or across a cast
  • the ON clause of MERGE (merge_into.slt)

Decorrelating these needs the column two levels up to be in scope where the predicate runs. The middle subquery can't output it without changing its row count, so this takes a dependent join: push the distinct values of the outer columns down as a domain, group or join on them, and join back.

Issues from the #5492 list that this PR does not change. Same results on main and on this branch:

Kind Issues
Wrong rows #26695, #26505, #26503, #26032, #26014, #25808, #24960, #21198
Unsupported #26508, #26710, #25789, #21201, #16137, #14554, #11773
Debug panic on differing join key types #26285

#26704 (subqueries in an aggregate) is also unchanged. It needs a new rewrite, not a fix to column identity.

What ids could offer #5492 (a proposal, not built here):

  • The domain is a copy of part of the outer plan. with_fresh_column_ids makes that copy without name clashes.
  • Mapping a correlated column to the domain column is a map from id to id, so it doesn't matter if the levels read the same table.
  • The verifier reports a reference to a column that is out of scope as a stale id, so a rewrite that gets this wrong fails in tests.
  • The rewrites in stage 9 and the DecorrelatePredicateSubquery and ScalarSubqueryToJoin rules could then be deleted.

What is the testing strategy for this PR?

  • datafusion/sqllogictest/test_files/column_identity.slt has a regression test for the fixed issues and for the shapes that must keep failing. Error when joining dataframes with duplicate column names if dataframes generated from file #14147 and Unparser with PostgreSqlDialect fails after Optimization Pass #21066 have Rust tests, as they need the DataFrame and unparser APIs.
  • The verifier (DATAFUSION_COLUMN_ID_VERIFY=1) checks after every analyzer and optimizer rule that references have an id, that the id is in scope, and that the name agrees. At the top of the stack it has no allowlist.
  • Top of the stack: the CI command passes with the verifier on (13,594 tests and 547 sqllogictest files), and so do 482 doctests, 116 datafusion-cli tests, and the examples.
  • Every stage was run separately with fmt, clippy, rustdoc and the CI command, with the verifier on from stage 4.
    • Exception: stages 4 and 5 fail leaf_pushdown_fuzz and leaf_pushdown_fuzz_extended, with the verifier on only. Both pass without the verifier, and from stage 6 on.
  • Expected plans change for the new names of common subexpressions and extracted columns, the fixed queries, and the plans of unsupported subqueries, which are now left in place.

Performance

Planning time was measured on the sql_planner benchmarks before rebasing onto the current main, against 934691e9af. Across 142 benchmarks the median change was +2.4% and the mean −0.8%.

Benchmark main This PR
physical_select_all_from_1000 10.2 ms 4.47 ms
logical_select_all_from_1000 4.03 ms 2.58 ms
physical_many_self_joins 3.18 ms 2.28 ms
logical_select_one_from_700 127 µs 130 µs
physical_select_one_from_700 344 µs 370 µs
physical_unnest_to_join 253 µs 274 µs
physical_intersection 228 µs 251 µs

Window benchmarks are 7–10% slower and TPC-H physical plans 5–8%. What remains is the extra passes over the plan: binding when the analyzer starts, the final relabel, and finding the highest id.

These numbers need rerunning. #26630 (a name index on DFSchema) has merged since. Lookups by id are still linear scans, so they may not share that gain, and wide schemas may compare worse than shown.

Are there any user-facing changes?

Yes. This needs the api change label. The upgrade guide (56.0.0.md) covers the API changes.

  • Queries: duplicate output names are allowed when they are different columns: SELECT a AS c1, b AS c1, SELECT ts, id, *.
  • EXPLAIN: common subexpressions and extracted expressions are named after their expressions.
  • API:
    • Column is #[non_exhaustive] and has an id.
    • Projection, Window, DistinctOn and Aggregate take NamedExprs.
    • Expr::GroupingSet no longer appears in Aggregate.group_expr.
    • OptimizerConfig::column_id_generator is required.
    • Constructors such as Union::try_new take a generator.
  • Behaviour:
    • Column and plan equality include ids, so two plans built separately are generally not ==.
    • A custom analyzer rule can't rename a query's outputs, because names are restored at the root.
    • The simplifier now reports canonicalizing and shortening an IN list as a transformation.
    • A table provider's schema with duplicate names no longer errors in physical planning.
    • A filter with a correlated subquery is no longer pushed down.

Open questions

  • Landing. Stages 1–2 could land alone, but deliver nothing to users yet. Could the equality change be done without a global switch, so that the work can land one operator at a time?
  • NamedExpr versus optional ids. Required ids on definitions make up a large part of stage 3. An alternative is optional ids enforced by the verifier, with NamedExpr as a later tightening.
  • The name fallback. References without ids still resolve by name in production. Removing it would break hand-built plans.
  • Extension nodes that compute columns have to opt in to ids.
  • Readability. Debug hides ids. A computed column keeps the name of its expression, so t.a + t.b * Int64(2) in a logical plan can mean the column named t.a + t.b, times 2. CAST keeps its input's name.

This PR was written with the help of an AI assistant (Claude Code).

🤖 Generated with Claude Code

@github-actions github-actions Bot added 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 labels Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 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 8f77b417a9b4fe0f143f50519db499b9de3f7a97
    Building datafusion v55.1.0 (current)
       Built [  51.885s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.031s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  15.942s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.745s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  71.007s] datafusion
    Building datafusion-common v55.1.0 (current)
       Built [  15.610s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.066s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [   1.055s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.067s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.896s] 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 Column.id in /home/runner/work/datafusion/datafusion/datafusion/common/src/column.rs:86

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  18.834s] datafusion-common
    Building datafusion-expr v55.1.0 (current)
       Built [  13.765s] (current)
     Parsing datafusion-expr v55.1.0 (current)
      Parsed [   0.095s] (current)
    Building datafusion-expr v55.1.0 (baseline)
       Built [   6.660s] (baseline)
     Parsing datafusion-expr v55.1.0 (baseline)
      Parsed [   0.097s] (baseline)
    Checking datafusion-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.748s] 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 Alias.id in /home/runner/work/datafusion/datafusion/datafusion/expr/src/expr.rs:717
  field Alias.kind in /home/runner/work/datafusion/datafusion/datafusion/expr/src/expr.rs:720

--- failure enum_struct_variant_field_added: pub enum struct variant field added ---

Description:
An enum's exhaustive struct variant has a new field, which has to be included when constructing or matching on this variant.
        ref: https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/enum_struct_variant_field_added.ron

Failed in:
  field id of variant SavedName::Saved in /home/runner/work/datafusion/datafusion/datafusion/expr/src/expr_rewriter/mod.rs:359

     Summary semver requires new major version: 2 major and 0 minor checks failed
    Finished [  23.569s] datafusion-expr
    Building datafusion-optimizer v55.1.0 (current)
       Built [  15.393s] (current)
     Parsing datafusion-optimizer v55.1.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion-optimizer v55.1.0 (baseline)
       Built [   6.726s] (baseline)
     Parsing datafusion-optimizer v55.1.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion-optimizer v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.190s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  23.255s] datafusion-optimizer
    Building datafusion-proto v55.1.0 (current)
       Built [  25.638s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.011s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  19.983s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.012s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.139s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  46.960s] datafusion-proto
    Building datafusion-sql v55.1.0 (current)
       Built [  12.400s] (current)
     Parsing datafusion-sql v55.1.0 (current)
      Parsed [   0.030s] (current)
    Building datafusion-sql v55.1.0 (baseline)
       Built [  11.729s] (baseline)
     Parsing datafusion-sql v55.1.0 (baseline)
      Parsed [   0.031s] (baseline)
    Checking datafusion-sql v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.280s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  25.525s] datafusion-sql
    Building datafusion-substrait v55.1.0 (current)
       Built [ 241.176s] (current)
     Parsing datafusion-substrait v55.1.0 (current)
      Parsed [   0.010s] (current)
    Building datafusion-substrait v55.1.0 (baseline)
       Built [  16.517s] (baseline)
     Parsing datafusion-substrait v55.1.0 (baseline)
      Parsed [   0.010s] (baseline)
    Checking datafusion-substrait v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.271s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [ 260.294s] datafusion-substrait

cetra3 and others added 9 commits November 6, 2026 11:21
Add ColumnId, the optional id on Column and on DFSchema fields, the per-session ColumnIdGenerator and a report-only verifier. Nothing allocates ids yet, so plans and behaviour do not change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… them

Scans and other leaves allocate ids, references bind to them, node schemas pass them through, and plans blended together are renumbered. The verifier runs in tests with an allowlist of rules that do not keep ids yet. Names still decide identity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every plan builder has a generator. The outputs of Projection, Window, DistinctOn and Aggregate become NamedExpr with a required id, and the columns nodes define without expressions (union, recursive query, unnest, join mark, grouping id) get ids too. Grouping sets are flattened out of Aggregate.group_expr.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Rules find columns by id and build references with ids. A TableScan keeps the ids of every source column. The allowlist shrinks to the rules with known remaining issues. Plans do not change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Carry ids through protobuf and Substrait, let extension nodes opt in, make the helpers and checks public for downstream code, and give MERGE INTO target columns ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The switch. Names come from definitions and are restored at the root and for writes, equality includes ids, outputs may share a name when their ids differ, columns with an id are looked up by id only, and rules no longer preserve names. __common_expr_N and __datafusion_extracted_N go away. Adds column_identity.slt with the bugs this fixes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Bind pulled up references by id (apache#26234, apache#26033), build join schemas with ids (apache#14147), fix fallback and join key id allocation, read by id where DataFrame methods rebuild columns, keep same-named UNNEST outputs apart, and keep rejecting ambiguous joins. Adds regression tests for apache#13476, apache#26620 and apache#21066.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Skip the name check when all fields have ids, build a scan's source schema only where needed, allocate a scan's ids in one block, compare id ranges before hashing when blending plans and subqueries, and renumber a CTE only from its second reference on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Targeted id-based fixes for apache#15558 (substitute through an equality of the middle subquery) and apache#21727 (flatten EXISTS or IN into EXISTS or IN), to rules slated for replacement by apache#5492. Shapes these do not cover now error with a stated reason. Remove the verifier's allowlist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@raghavyadav01

Copy link
Copy Markdown
Contributor

Ran the decorrelation corpus from #26904 (57 queries) on this branch (e0677c4) and on its merge base 888879d, against DuckDB 1.5.6.

So the binding part clears a whole group of the open decorrelation issues without changing the rest.

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

2 participants