Skip to content

fix: infer UNION output types before analysis - #27139

Open
kumarUjjawal wants to merge 1 commit into
apache:mainfrom
kumarUjjawal:kumarUjjawal/datafusion-27023-fix
Open

kumarUjjawal wants to merge 1 commit into
apache:mainfrom
kumarUjjawal:kumarUjjawal/datafusion-27023-fix

Conversation

@kumarUjjawal

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

DataFrame::schema() previously reported the first UNION input's type while execution used the common type. UNION construction now infers the common output type.

What changes are included in this PR?

  • Infer UNION and CASE result types before analysis, with cast insertion deferred to the analyzer.
  • Derive final UNION types from actual inputs with the shared coerce_union_type helper.
  • A branch-56 backport remains a maintainer decision because public inference behavior and one executed Arrow type change.

Synthetic nested CASE planning (ci-bench, SQL through physical planning):

Depth Main (µs) Patch (µs) Extra (µs)
8 101.2 105.5 +4.3
32 270.1 317.6 +47.5
64 502.8 684.0 +181.2

These three points suggest roughly quadratic extra work. Extrapolating that trend to depth 1,000 gives about 45 ms of extra planning. The benchmark did not measure depth 1,000.

What is the testing strategy for this PR?

The extended suite (13,511 Rust tests), all 547 SLT files, full lint suite, all-features Clippy, and focused regressions pass.

Are there any user-facing changes?

The upgrade note in docs/source/library-user-guide/upgrading/56.0.0.md covers the change of x from Dictionary(Int32, Utf8) to Utf8 in this query and the other inference changes.

SELECT 1 AS y
UNION ALL BY NAME
SELECT arrow_cast('a', 'Dictionary(Int32, Utf8)') AS x;

@kumarUjjawal kumarUjjawal added api change Changes the API exposed to users of the crate bug Something isn't working labels Nov 5, 2026
@kumarUjjawal
kumarUjjawal requested a review from alamb November 5, 2026 17:14
@sdf-jkl

sdf-jkl commented Nov 5, 2026

Copy link
Copy Markdown
Member

I was just about to post a fix too 😭

@sdf-jkl

sdf-jkl commented Nov 5, 2026

Copy link
Copy Markdown
Member

Should've taken the issue yesterday

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely a patch, but I think it is ok for now.

It seems like to fix this for real, we should do what @findepi told us (namely build correct Union plans in the first place)

/// alone is not enough here -- it reflects the raw, pre-`Analyzer` plan (see
/// `SessionState::create_logical_plan`), which for a `UNION` still has the
/// first leg's un-coerced type.
/// This test checks the schema after the Analyzer and Optimizer run,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for trimming the AI verbosity

in this section pertains to features and changes that have already been merged
to the main branch and are awaiting release in this version.

### UNION and CASE infer common output types before analysis

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug fix. Why does it need a upgrade guide entry?

`datafusion_expr::type_coercion::other::coerce_union_type` is a new helper
that infers one UNION column type from input fields.

For `UNION BY NAME`, missing columns participate as `NULL` during type

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to document the behavior of how schema resolution works, it should be in the code documentation I think

.transpose()?;
// If the branches have no common type, keep the old provisional
// type. The analyzer reports the error.
Ok(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could probably make this more concse / less indented with some intermediate variables

pub fn coerce_union_schema(inputs: &[Arc<LogicalPlan>]) -> Result<DFSchema> {
coerce_union_schema_with_schema(&inputs[1..], inputs[0].schema())
let Some(first) = inputs.first() else {
return plan_err!("UNION requires at least one input");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a nice cleanup

}

#[tokio::test]
async fn union_coercion_preserves_values() -> Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like most of these tests can be written as .slt tests (but better). I recommend leaving some tests here that cover the expected schema case but don't run the query -- instead run the queries as a .slt tests

@alamb

alamb commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Thank you for running with this @kumarUjjawal

sdf-jkl added a commit to sdf-jkl/datafusion that referenced this pull request Nov 5, 2026
Incorporate deferred CASE/UNION coercion errors, dictionary coercion from
actual inputs, and an empty-input guard, informed by:
apache#27139

Keep regression coverage in SQL and document the dictionary output-type
change for UNION BY NAME.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api change Changes the API exposed to users of the crate bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REGRESSION: UNION: DataFrame::schema() reports the first input's type, but execution returns the coerced type

3 participants