Skip to content

fix: UnionExec now conforms each batch to the union's declared schema - #23861

Merged
alamb merged 3 commits into
apache:mainfrom
dariocurr:fix/union-nullable-schema-mismatch
Aug 4, 2026
Merged

fix: UnionExec now conforms each batch to the union's declared schema#23861
alamb merged 3 commits into
apache:mainfrom
dariocurr:fix/union-nullable-schema-mismatch

Conversation

@dariocurr

@dariocurr dariocurr commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

UNION ALL between an input whose column is NOT NULL and an input where the same column is nullable produces a valid, correctly-typed logical plan — the analyzer already OR's nullability across legs in coerce_union_schema (datafusion/optimizer/src/analyzer/type_coercion.rs), so the union's declared schema correctly reports the field as nullable.

The bug is at execution time: UnionExec::execute() hands out each child's RecordBatches completely unchanged. A leg whose column was already NOT NULL (and therefore needed no CAST from the analyzer) keeps emitting batches with a NOT NULL field, contradicting the union's own declared (nullable) schema. DataFusion's own execution tolerates this silently, but any consumer that checks schema equality across batches from the same stream — most notably pyarrow.Table.from_batches via the Arrow C Stream FFI used by the datafusion Python bindings — rejects the stream with ArrowInvalid: Schema at index N was different, even though every individual SELECT runs fine on its own.

Minimal reproducible example (Python)

import pyarrow as pa
from datafusion import SessionContext

ctx = SessionContext()
ctx.register_record_batch(
    "table_a",
    pa.record_batch(
        {"id": [1, 2], "status": ["ok", "ok"]},
        schema=pa.schema([("id", pa.int64()), ("status", pa.string())]),  # NOT NULL
    ),
)
ctx.register_record_batch(
    "table_b",
    pa.record_batch(
        {"id": [3, 4], "status": ["done", None]},
        schema=pa.schema([("id", pa.int64()), ("status", pa.string(), True)]),  # nullable
    ),
)

df = ctx.sql("SELECT id, status FROM table_a UNION ALL SELECT id, status FROM table_b")
print(df.schema())  # status: string, nullable -- correct
df.to_pandas()      # raises pyarrow.lib.ArrowInvalid: Schema at index 1 was different

The same root cause is why #16627 had to make the sqllogictest convert_batches helper tolerant of this exact mismatch instead of failing, and why #15603 (stale, closed for inactivity) attempted a similar fix at the physical-execution layer but didn't land.

What changes are included in this PR?

  • datafusion/physical-plan/src/union.rs: UnionExec::execute() now compares each child stream's schema against UnionExec's own declared schema, and if they disagree, wraps the child stream in a small new SchemaConformingStream that re-stamps every batch with the union's schema before yielding it. This is always safe: the union's schema can only be more permissive than any single input's (nullability is combined with logical OR, never narrowed — see the existing coerce_union_schema docs), and only the Field::nullable metadata changes; the underlying array data and data type are untouched.
  • datafusion/core/tests/sql/union_nullable.rs (new): regression tests covering same-type nullable/non-nullable mismatches in both leg orders, the "both legs NOT NULL" case (schema should stay NOT NULL), and a case where one leg also needs a real CAST (Int32 -> Int64) in addition to the nullability fix.

InterleaveExec (used for sorted unions) may have an analogous issue, but I kept this PR scoped to plain UnionExec, which is what's reported in #23862 / #15394 and reproduces the Python-binding failure above.

Are these changes tested?

Yes — added datafusion/core/tests/sql/union_nullable.rs with 4 new tests. I verified each one fails with a clear schema-mismatch assertion on main (i.e. before this fix) and passes with it applied. Also ran the full datafusion-physical-plan and datafusion-optimizer unit suites, union.slt/union_by_name.slt sqllogictests, and cargo fmt/clippy (--no-deps, since an unrelated pre-existing dead-code lint in datafusion-physical-expr fails -D warnings on main even without this change).

Are there any user-facing changes?

UNION ALL results now consistently report the analyzer's declared nullability on every batch, regardless of which leg produced it. No public API changes.

@codecov-commenter

codecov-commenter commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.19178% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.87%. Comparing base (3f81613) to head (4af8735).
⚠️ Report is 107 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/union.rs 82.19% 7 Missing and 6 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23861      +/-   ##
==========================================
+ Coverage   80.71%   80.87%   +0.16%     
==========================================
  Files        1090     1101      +11     
  Lines      370339   375837    +5498     
  Branches   370339   375837    +5498     
==========================================
+ Hits       298926   303967    +5041     
- Misses      53603    53757     +154     
- Partials    17810    18113     +303     

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

UNION ALL between an input with a NOT NULL column and one where the same
column is nullable produced a valid, correctly-typed logical plan (the
analyzer already OR's nullability across legs in coerce_union_schema), but
UnionExec::execute() handed out each child's RecordBatches completely
unchanged. The leg whose column was already NOT NULL (and so needed no
CAST) kept emitting batches with a NOT NULL field, contradicting the
union's own declared (nullable) schema.

Any consumer that checks schema equality across batches from the same
stream -- e.g. pyarrow.Table.from_batches via the Arrow C Stream FFI used
by the datafusion Python bindings -- then rejects the stream with
`ArrowInvalid: Schema at index N was different`, even though every
individual SELECT runs fine on its own and DataFusion's own execution
never errors.

UnionExec::execute() now re-stamps each child's batches with the union's
own schema whenever they disagree. This is always safe: the union's schema
can only be more permissive than any single input's (nullability is OR'd,
never narrowed), and only the Field::nullable flag changes -- the
underlying array data and data type are untouched.

Closes apache#15394.
@dariocurr
dariocurr force-pushed the fix/union-nullable-schema-mismatch branch from c122156 to bed030d Compare July 24, 2026 13:30
@github-actions github-actions Bot added core Core DataFusion crate physical-plan Changes to the physical-plan crate labels Jul 24, 2026
@dariocurr

Copy link
Copy Markdown
Contributor Author

Is there any news on that?

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

@dariocurr
Thanks for working on this schema consistency issue.

The UnionExec fix looks useful, but the same mismatch can still occur after the optimizer replaces it with InterleaveExec. I have left one blocking comment for that path and one small test documentation suggestion.

input_stream_vec.push(input.execute(partition, Arc::clone(&context))?);
} else {
// Do not find a partition to execute
break;

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 think the same schema mismatch can still surface when the optimizer replaces a UnionExec with an InterleaveExec.

InterleaveExec constructs its declared schema using the same union_schema helper, but execute() passes the child streams directly to CombinedRecordBatchStream. Its poll_next implementation then returns each child RecordBatch unchanged.

Since ensure_distribution can rewrite a UnionExec to an InterleaveExec when the children are interleavable (datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs:1493), a SQL-visible UNION ALL plan may still emit batches whose schemas do not match the declared interleave or union schema.

Could you apply the same schema-conforming wrapper to each InterleaveExec child stream, or update CombinedRecordBatchStream so yielded batches are re-stamped with its declared schema?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks. Pushed 131796d: InterleaveExec::execute() now wraps each child stream through the same conform_stream_schema/SchemaConformingStream helper (extracted from the UnionExec path) before handing them to CombinedRecordBatchStream, so batches are re-stamped with the interleave's declared schema whenever nullability disagrees. Added test_interleave_conforms_batch_schema (in union.rs) covering this directly.

// specific language governing permissions and limitations
// under the License.

//! Regression tests for `UNION ALL` between inputs where the same column is

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.

The regression coverage is helpful. The module-level comment currently repeats much of the PR and issue description, though.

Could we trim it down to the invariant being tested and the issue link? The assertions already make the downstream PyArrow failure mode clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Trimmed in 131796d — the module doc-comment is now just the invariant being tested plus the issue link.

UnionExec::execute() already re-stamped each child's batches with the
union's declared schema whenever nullability disagreed, but the same
mismatch was still reachable through InterleaveExec: ensure_distribution
can rewrite a UnionExec into an InterleaveExec when the children are
interleavable, and InterleaveExec::execute() handed CombinedRecordBatchStream
the child streams unchanged.

Extract the existing UnionExec fix into a small conform_stream_schema
helper and apply it to each InterleaveExec child stream before combining.
Also trim the union_nullable.rs module doc-comment down to the invariant
and issue link, per review feedback.

Addresses review comments from kosiew on PR apache#23861.
@kosiew

kosiew commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@dariocurr
I would love to review this again, after you fix the clippy errors.

An Opus-model review of 131796d caught three loose ends:

- The SchemaConformingStream/conform_stream_schema doc comment claimed
  InterleaveExec::try_new re-validates child field data types the same
  way UnionExec::try_new does via calculate_union. It doesn't --
  InterleaveExec::compute_properties builds fresh EquivalenceProperties
  without that check. Corrected the comment to state the real
  invariant (InterleaveExec inputs come from an already-validated
  UnionExec via the optimizer) and note that a genuine mismatch would
  now surface as an explicit error rather than a silently corrupt batch.

- test_interleave_conforms_batch_schema and
  union_all_widening_cast_also_fixes_nullable iterated collected
  batches without asserting the batch list was non-empty, so both
  could pass vacuously if no rows came through.

- union_nullable_spill.rs's regression test doc comment asserted
  UnionExec returns child streams "without schema coercion" -- no
  longer true after this fix. Updated it to explain the test now
  guards the SpillManager-level fix (apache#21292) specifically, since
  UnionExec itself no longer produces mismatched-nullability batches.
@dariocurr

dariocurr commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up in 4af8735, fixing a few loose ends from a closer pass:

  • SchemaConformingStream's doc comment overstated the guarantee for InterleaveExec -- unlike UnionExec::try_new, InterleaveExec::compute_properties doesn't re-validate that child field types match. Comment now states the real invariant and notes a mismatch would surface as an error, not a silent bad batch.
  • Two tests iterated batches without asserting the list was non-empty, so they could pass vacuously.
  • union_nullable_spill.rs's comment claimed UnionExec skips schema coercion, which this PR makes false. Updated to describe what it now guards.

Tests, fmt, and clippy still clean.

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

@dariocurr
Thanks for the follow-up.

The earlier feedback has been addressed:

  • InterleaveExec now conforms each child stream before passing it to CombinedRecordBatchStream, and the direct regression test confirms that every emitted batch uses the interleave schema.
  • The SQL test module comment now focuses on the invariant and the related issue link.
  • The documentation guarantee now matches the actual constructor and runtime behavior.
  • Both batch loops that were previously vacuous now verify that output is non-empty.
  • The spill test comment is up to date.

I did not find any new defects or have any inline comments.

Looks good to me. Approving.

@alamb

alamb commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This is a nice change -- thank you @dariocurr and @kosiew -- ideally we could coerce the schema at plan time but I don't know how to coerce nullability

@alamb
alamb added this pull request to the merge queue Aug 4, 2026
Merged via the queue into apache:main with commit e948f17 Aug 4, 2026
40 checks passed
@dariocurr
dariocurr deleted the fix/union-nullable-schema-mismatch branch August 5, 2026 00:03
@dariocurr

Copy link
Copy Markdown
Contributor Author

Following up on the schema-coercion-at-plan-time idea from this thread: opened #24094, which moves the re-stamping from inline logic in execute() into an explicit CoerceSchemaExec node inserted by UnionExec::try_new/InterleaveExec::try_new. It shows up in EXPLAIN now. Benchmarked it against this PR's approach and didn't find a measurable performance difference either way.

dariocurr added a commit to dariocurr/datafusion that referenced this pull request Aug 5, 2026
Follow-up to apache#23861. Per review feedback, moves the nullability
re-stamping from inline logic in UnionExec/InterleaveExec::execute()
into an explicit CoerceSchemaExec plan node, inserted by try_new above
any child whose schema disagrees with the computed union schema. The
coercion is now visible in EXPLAIN instead of hidden at runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dariocurr added a commit to dariocurr/datafusion that referenced this pull request Aug 5, 2026
Follow-up to apache#23861 (issue apache#15394). Moves the schema re-stamping for
nullability-mismatched UNION ALL / INTERLEAVE inputs out of `execute()`
and into plan construction, via a new `CoerceSchemaExec` node inserted by
`UnionExec::try_new`/`InterleaveExec::try_new` whenever a child's own
schema disagrees with the computed union schema. The node is now visible
in `EXPLAIN` output, is transparent for statistics/pushdown/proto
purposes, and adds no measurable overhead versus inline re-stamping.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UNION ALL between NOT NULL and nullable columns produces batches with inconsistent nullability

4 participants