Skip to content

Untyped aliased placeholder in a UNION arm fails with "Schema error: No field named <alias>" when the plan is analyzed or optimized #25669

Description

@adriangb

Describe the bug

A UNION ALL whose arms select an untyped placeholder under an alias (SELECT $1 AS a UNION ALL SELECT $2 AS a) cannot be analyzed or optimized before the placeholders are bound. The error names the alias as a missing column:

Schema error: No field named a.

This affects two public paths:

  1. PREPARE p AS SELECT $1 AS a UNION ALL SELECT $2 AS a fails in the optimizer rule optimize_unions.
  2. SessionState::optimize on the plan from SessionState::create_logical_plan (placeholders still unbound) fails in the analyzer rule type_coercion. Optimizing first and binding with LogicalPlan::replace_params_with_values afterwards is therefore impossible for this shape.

Binding first (DataFrame::with_param_values, then execute) works. A single arm without UNION works. The same UNION without the alias (SELECT $1 UNION ALL SELECT $2) works. Declaring the placeholder type works: CAST($1 AS VARCHAR) AS a, or PREPARE p(VARCHAR, VARCHAR) AS ....

EXPLAIN SELECT $1 AS a UNION ALL SELECT $2 AS a shows the analyzer failure:

| logical_plan after type_coercion             | Schema error: No field named a. |

To Reproduce

datafusion-cli

PREPARE p AS SELECT $1 AS a UNION ALL SELECT $2 AS a;

Actual:

Optimizer rule 'optimize_unions' failed
caused by
Schema error: No field named a.

The same happens in a CTE that is joined to a table, which is the realistic shape (a small list of key pairs supplied as parameters):

CREATE TABLE t (k1 VARCHAR, k2 VARCHAR) AS VALUES ('a0', 'b0'), ('a1', 'b1'), ('a2', 'b2');

PREPARE q AS
WITH keys AS (
  SELECT $1 AS k1, $2 AS k2
  UNION ALL
  SELECT $3 AS k1, $4 AS k2
)
SELECT t.k1 FROM t JOIN keys ON t.k1 = keys.k1 AND t.k2 = keys.k2;
Optimizer rule 'optimize_unions' failed
caused by
Schema error: No field named k1.

Rust (optimize, then bind)

use datafusion::common::{ParamValues, ScalarValue};
use datafusion::error::Result;
use datafusion::prelude::*;

#[tokio::main]
async fn main() -> Result<()> {
    let ctx = SessionContext::new();
    let state = ctx.state();
    let plan = state
        .create_logical_plan("SELECT $1 AS a UNION ALL SELECT $2 AS a")
        .await?;

    // Fails here:
    //   type_coercion
    //   caused by
    //   Schema error: No field named a.
    let optimized = state.optimize(&plan)?;

    let bound = optimized.replace_params_with_values(&ParamValues::List(vec![
        ScalarValue::from("x").into(),
        ScalarValue::from("y").into(),
    ]))?;
    let _ = state.optimize(&bound)?;
    Ok(())
}

Debug output of the error:

Context("type_coercion", SchemaError(FieldNotFound { field: Column { relation: None, name: "a" }, valid_fields: [] }, Some("")))

Named placeholders (SELECT $x AS a UNION ALL SELECT $y AS a) fail the same way.

Expected behavior

The plan analyzes and optimizes with the placeholders unbound, as it does for the same query without UNION and for the same query with CAST($1 AS VARCHAR). After binding, SELECT $1 AS a UNION ALL SELECT $2 AS a with ('x', 'y') returns the two rows x and y.

Additional context

The unoptimized plan is:

Union [a:Null;N]
  Projection: $1 AS a [a:Null;N]
    EmptyRelation: rows=1 []
  Projection: $2 AS a [a:Null;N]
    EmptyRelation: rows=1 []

Both failing rules (TypeCoercionRewriter::coerce_union in the analyzer and OptimizeUnions in the optimizer) call coerce_plan_expr_for_schema on each UNION arm. For a Projection arm, that function calls coerce_exprs_for_schema(expr, input.schema(), union_schema), which calls expr.get_type(input.schema()) on each projection expression.

Expr::get_type in datafusion/expr/src/expr_schema.rs has a special case for an alias of an untyped placeholder:

Expr::Alias(Alias { expr, name, .. }) => match &**expr {
    Expr::Placeholder(Placeholder { field, .. }) => match &field {
        None => schema.data_type(&Column::from_name(name)).cloned(),
        Some(field) => Ok(field.data_type().clone()),
    },
    _ => expr.get_type(schema),
},

When the placeholder has no type, it looks up the alias name (a) as a column in the schema that it receives. Here that schema is the projection's input (EmptyRelation, no fields), so the lookup fails. The special case appears to assume that the schema is the projection's own output schema. It came from #4701 (prepared statement parameter type inference).

Expr::to_field does not have this special case. For the same expression it returns a Null field, so Projection::try_new builds the schema a:Null without error. get_type and to_field therefore disagree for Alias(Placeholder { field: None }).

Possible fixes:

  • In the Alias(Placeholder { field: None }) branch of get_type, return DataType::Null (the same result as a bare untyped placeholder and as to_field) when the alias name is not in the schema, or remove the branch.
  • Or make coerce_exprs_for_schema resolve the current type of a projection expression from the projection's output schema (the field at the same index), not from the input schema.

Related, but different:

Version

  • apache/datafusion main at commit 7570366fd929daf9ced744bb8397686b50565b18 (2026-09-23, workspace version 55.1.0), both with datafusion-cli built from that commit and with the Rust program above
  • Also reproduced with released datafusion-cli 54.0.0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions