Skip to content

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

Closed

Description

@mikewolfd

Describe the bug

For a UNION ALL whose inputs have different column types, DataFrame::schema() (the plan as built by the SQL planner) reports the first input's type, but the batches the query produces have the type chosen later by the TypeCoercion analyzer. A caller that checks results against the declared schema sees a type change between planning and execution.

#14380 describes the root cause (the Union node takes the left input's schema and is only coerced later). This issue is about a user-visible effect, made more common by a 54.0 change: union coercion now uses type_union_coercion, which picks the finer timestamp unit. 53.1 kept the left unit in the case below, so declared and executed types used to agree.

To Reproduce

use datafusion::prelude::*;

#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
    let ctx = SessionContext::new();
    for sql in [
        // Timestamp(us, UTC) UNION ALL CAST(NULL AS TIMESTAMP), i.e. Timestamp(ns, None)
        "SELECT t FROM (SELECT arrow_cast(arrow_cast(1, 'Timestamp(Microsecond, None)'), \
         'Timestamp(Microsecond, Some(\"UTC\"))') AS t UNION ALL SELECT CAST(NULL AS TIMESTAMP) AS t) u",
        // Utf8 UNION ALL LargeUtf8
        "SELECT s FROM (SELECT arrow_cast('a', 'Utf8') AS s \
         UNION ALL SELECT arrow_cast('b', 'LargeUtf8') AS s) u",
    ] {
        let df = ctx.sql(sql).await?;
        let declared = df.schema().field(0).data_type().clone();
        let executed = df.collect().await?[0].schema().field(0).data_type().clone();
        println!("declared={declared:?} executed={executed:?}");
    }
    Ok(())
}

Output on 55.2.0:

declared=Timestamp(Microsecond, Some("UTC")) executed=Timestamp(Nanosecond, Some("UTC"))
declared=Utf8 executed=LargeUtf8

Output on 53.1.0:

declared=Timestamp(Microsecond, Some("UTC")) executed=Timestamp(Microsecond, Some("UTC"))
declared=Utf8 executed=LargeUtf8

(Utf8 UNION ALL Utf8View behaves the same way: declared Utf8, executed Utf8View, on both versions.)

Expected behavior

DataFrame::schema() should describe the batches the query returns: either the Union is built with the coerced schema (#14380), or the declared schema reflects the analyzer's coercion.

Additional context

Found while upgrading an embedded engine from 53.1 to 55.2. Its export path checks every executed batch against the declared schema, and this mismatch refused queries that had passed on 53.1. The workaround is to cast every union branch to one explicit type.

Activity

  1. added
    regressionSomething that used to work no longer does
    on Nov 3, 2026
  2. alamb commented on Nov 3, 2026

    @alamb
    Contributor

    Thanks @mikewolfd -- I added this to the list of things to fix for 56:

  3. added
    bugSomething isn't working
    on Nov 3, 2026
  4. changed the title [-]UNION: DataFrame::schema() reports the first input's type, but execution returns the coerced type[/-] [+]REGRESSION: UNION: DataFrame::schema() reports the first input's type, but execution returns the coerced type[/+] on Nov 3, 2026
  5. alamb commented on Nov 3, 2026

    @alamb
    Contributor

    I reproduced this and confirmed the timestamp case is a regression, so I have marked this issue as such.

    I ran the program from the description (plus a Utf8 / Utf8View case) against datafusion 53.1.0, 54.1.0, 55.2.0 and current main (181a87a):

    == datafusion 53.1.0 ==
    OK       declared=Timestamp(Microsecond, Some("UTC")) executed=Timestamp(Microsecond, Some("UTC"))
    MISMATCH declared=Utf8 executed=LargeUtf8
    MISMATCH declared=Utf8 executed=Utf8View
    == datafusion 54.1.0 ==
    OK       declared=Timestamp(Microsecond, Some("UTC")) executed=Timestamp(Microsecond, Some("UTC"))
    MISMATCH declared=Utf8 executed=LargeUtf8
    MISMATCH declared=Utf8 executed=Utf8View
    == datafusion 55.2.0 ==
    MISMATCH declared=Timestamp(Microsecond, Some("UTC")) executed=Timestamp(Nanosecond, Some("UTC"))
    MISMATCH declared=Utf8 executed=LargeUtf8
    MISMATCH declared=Utf8 executed=Utf8View
    == datafusion main ==
    MISMATCH declared=Timestamp(Microsecond, Some("UTC")) executed=Timestamp(Nanosecond, Some("UTC"))
    MISMATCH declared=Utf8 executed=LargeUtf8
    MISMATCH declared=Utf8 executed=Utf8View
    

    Summary:

    • Timestamp case: regression in 55.0.0 (not 54.0 as the description guesses). It was introduced by fix: preserve timestamp precision when coercing mixed time units #22759 (51673d0, "fix: preserve timestamp precision when coercing mixed time units"), which changed timeunit_coercion to pick the finer unit. Before that change Timestamp(us, UTC) UNION ALL Timestamp(ns, None) coerced to Timestamp(us, UTC), which happened to match the first input and so the declared schema. Now it coerces to Timestamp(ns, UTC) and the declared schema no longer matches.
    • Utf8 / LargeUtf8 and Utf8 / Utf8View cases: not a regression. They mismatch on every version tested, back to 53.1.0. That is the long-standing root cause tracked in Create UNION plan node with correct schema #14380: the Union node is built with the first input's schema and only coerced later by the TypeCoercion analyzer.

    So the underlying bug is old, but #22759 widened the set of queries where it is visible, and in particular broke the timestamp query above which passed on 53.1 and 54.1.

  6. self-assigned this
    on Nov 5, 2026
  7. added a commit that references this issue on Nov 6, 2026
    13bd9d1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingregressionSomething that used to work no longer does

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions