Repository navigation
Description
Describe the bug
ProjectionExec::gather_filters_for_pushdown silently rewrites filter predicates to the wrong source column when the projection's output schema contains duplicate column names. This produces incorrect query results/missing rows with no error.
The root cause is two functions that use name-only schema lookups, which always return the first match:
Bug 1 — collect_reverse_alias (projection.rs): Builds the reverse alias map using column_with_name(&alias), which always returns the first column with that name. When two projection outputs share a name (e.g., id at positions 0 and 2), both iterations produce the same HashMap key Column("id", 0), and the second silently overwrites the first.
// column_with_name always returns the FIRST match
let (aliased_index, _) = self.projector.output_schema().column_with_name(&projection.alias)?; // ← always returns 0 for duplicate names
let aliased_col = Column::new(&projection.alias, aliased_index);
alias_map.insert(aliased_col, Arc::clone( & projection.expr)); // ← overwrites on collisionBug 2 — FilterRemapper::try_remap (filter_pushdown.rs): Uses schema.index_of(col.name()) which also returns the first match. A filter on id@2 (the second occurrence) gets silently rewritten to id@0.
// index_of always returns the FIRST match
let Ok(new_index) = self.child_schema.index_of(col.name())
// col was id@2 → new_index is 0 → rewrites to id@0To Reproduce
Construct a physical plan where a ProjectionExec produces duplicate column names from a join, with a FilterExec above it:
FilterExec: id@0 = 3 ← checks output col 0 (right side's id)
ProjectionExec: [right_id@2 as id, ← output col 0 (from RIGHT)
name@1,
left_id@0 as id] ← output col 2 (from LEFT)
HashJoinExec: Left
left: [left_id, name] (columns 0-1)
right: [right_id] (column 2)
The filter id@0 = 3 should check the right side's id. After FilterPushdown:
- Bug 1 overwrites
Column("id", 0) → right_id@2withColumn("id", 0) → left_id@0 - The filter gets remapped to
left_id@0 = 3— filtering the wrong column - Query returns incorrect results
Here's the test to reproduce this issue, for convenience add it to datafusion/core/tests/physical_optimizer/filter_pushdown.rs. It constructs the physical plan directly and runs it before and after FilterPushdown. The original plan returns 3 correct rows; the optimized plan returns 0. You can run this test with:
cargo test -p datafusion --test core_integration -- test_filter_pushdown_projection_duplicate_column_names --no-capture/// Reproduces a bug where FilterPushdown through ProjectionExec with duplicate
/// column names remaps filter predicates to the wrong source column.
///
/// Plan structure:
///
/// FilterExec: id@0 IS NULL ← checks output col 0 (right side's id)
/// ProjectionExec: [right_id@2 as id, ← output col 0 (from RIGHT)
/// name@1,
/// left_id@0 as id] ← output col 2 (from LEFT)
/// HashJoinExec: Left
/// left: [left_id, name] (columns 0-1)
/// right: [right_id] (column 2)
///
/// Bug 1 overwrites Column("id", 0) → left_id@0 instead of right_id@2.
/// The filter `id@0 IS NULL` gets remapped to `left_id@0 IS NULL` (wrong side).
#[tokio::test]
async fn test_filter_pushdown_projection_duplicate_column_names() {
use datafusion_common::JoinType;
use datafusion_physical_expr::expressions::is_null;
use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode};
// Left table: orders (5 rows, all non-NULL left_id)
let left_batches = vec![record_batch!(
("left_id", Int32, [1, 2, 3, 4, 5]),
("name", Utf8, ["Alice", "Bob", "Charlie", "Diana", "Eve"])
)
.unwrap()];
let left_schema = Arc::new(Schema::new(vec![
Field::new("left_id", DataType::Int32, false),
Field::new("name", DataType::Utf8, false),
]));
let left_scan = TestScanBuilder::new(Arc::clone(&left_schema))
.with_batches(left_batches)
.build();
// Right table: returns (2 rows matching orders 1 and 3)
let right_batches = vec![
record_batch!(("right_id", Int32, [1, 3])).unwrap(),
];
let right_schema = Arc::new(Schema::new(vec![Field::new(
"right_id",
DataType::Int32,
false,
)]));
let right_scan = TestScanBuilder::new(Arc::clone(&right_schema))
.with_batches(right_batches)
.build();
// HashJoinExec: LEFT JOIN on left_id = right_id
// Join output schema: [left_id(0), name(1), right_id(2)]
let join = Arc::new(
HashJoinExec::try_new(
left_scan,
right_scan,
vec![(
col("left_id", &left_schema).unwrap(),
col("right_id", &right_schema).unwrap(),
)],
None,
&JoinType::Left,
None,
PartitionMode::CollectLeft,
datafusion_common::NullEquality::NullEqualsNothing,
false,
)
.unwrap(),
);
let join_schema = join.schema();
// ProjectionExec: creates duplicate "id" columns
// output col 0: right_id@2 AS id (from RIGHT side, NULL for unmatched)
// output col 1: name@1
// output col 2: left_id@0 AS id (from LEFT side, never NULL)
let projection = Arc::new(
ProjectionExec::try_new(
vec![
(col("right_id", &join_schema).unwrap(), "id".to_string()),
(col("name", &join_schema).unwrap(), "name".to_string()),
(col("left_id", &join_schema).unwrap(), "id".to_string()),
],
join,
)
.unwrap(),
);
// FilterExec: id@0 IS NULL
// This should check the RIGHT side's id (output col 0 = right_id).
let filter_expr = is_null(Arc::new(Column::new("id", 0))).unwrap();
let plan = Arc::new(
FilterExec::try_new(filter_expr, projection).unwrap(),
) as Arc<dyn ExecutionPlan>;
// Apply the physical FilterPushdown optimizer
let config = ConfigOptions::default();
let optimized = FilterPushdown::new()
.optimize(Arc::clone(&plan), &config)
.unwrap();
// Execute both plans and compare
let session_ctx = SessionContext::new_with_config(SessionConfig::new());
session_ctx.register_object_store(
ObjectStoreUrl::parse("test://").unwrap().as_ref(),
Arc::new(InMemory::new()),
);
let task_ctx = session_ctx.state().task_ctx();
let original_batches = collect(Arc::clone(&plan), Arc::clone(&task_ctx))
.await
.unwrap();
let original_count: usize =
original_batches.iter().map(|b| b.num_rows()).sum();
let optimized_batches = collect(optimized, Arc::clone(&task_ctx))
.await
.unwrap();
let optimized_count: usize =
optimized_batches.iter().map(|b| b.num_rows()).sum();
// Original plan correctly returns 3 rows (orders 2, 4, 5 — unmatched)
assert_eq!(original_count, 3, "original plan should return 3 rows");
// BUG: optimized plan returns 0 rows — filter was remapped to wrong column
// After fix, change to: assert_eq!(optimized_count, 3);
assert_eq!(optimized_count, 0,
"BUG: filter pushed to wrong column, expected 3 rows but got 0");
}Expected behavior
The filter should remain mapped to right_id@2 and correctly filter on the right side's column.
Additional context
The alias-aware pushdown was added in #19404. Both column_with_name and index_of are Arrow schema methods that return the first match by design and the code needs to use positional indexing instead. The bug affects any use of the physical plan API where plans are constructed directly instead of building plans via the LogicalPlanBuilder. This code path is not exercised through normal SQL because the logical optimizer's PushDownFilter resolves qualified column references and pushes filters below projections before the physical plan is created.
Activity
alamb commentedon Jul 15, 2026
Can we reproduce this with SQL? Or is it only for progrmatically constructed plans?
msaule commentedon Aug 1, 2026
I reproduced this with a programmatically constructed physical plan. SQL is not a reproducer: DataFusion's SQL planner rejects duplicate projection expression names before it creates a physical plan. The failure occurs when a
ProjectionExecexposes duplicateidaliases and a parent filter references one by physical index. The current pushdown path resolves the alias by name, so it can rewrite the filter to the otheridsource column.I have a focused patch and regression test that preserve the output column index while expanding projection aliases. It covers both duplicate
idpositions and verifies that each filter is rewritten to its own original input column. If programmatic physical plans are in scope for this rule, I can open the PR.alamb commentedon Aug 3, 2026
Well I think the question is "is the programatically created plan valid, or does it violate some (potentially unstated) invariant"?
If there is some undocumented / unenforced invariant, we could potentially look into enforcing that / erroring rather than allowing
msaule commentedon Aug 3, 2026
Thanks, that makes sense.
ProjectionExec::try_newcurrently accepts duplicate aliases and physical columns carry an index, so the plan is executable today. but since SQL rejects this shape earlier, I agree that does not necessarily mean it is supported.so i'd love to hear what you would prefer, that I make
ProjectionExec::try_newreject duplicate output names with a test, or should filter pushdown preserve the column index for directly constructed physical plans?alamb commentedon Aug 3, 2026
I woudl recommend starting with enforcing the "no duplicated column names" invariant in
ProjectionExec::try_newand see how far that gets us. I think we can justify starting to error by the fact that the plan an produce wrong resultsIn general I think DataFusion requires unique names in its schemas so we can refer to this as well:
msaule commentedon Aug 3, 2026
thanks, i took that approach and opened #24073. it rejects duplicate output aliases in
ProjectionExec::try_newand adds a regression test for the failing construction. the physical-plan test suite and clippy pass. happy to adjust the scope or error wording if you’d prefer a different boundary!alamb commentedon Aug 8, 2026
#24073 looks good to me
haohuaijin commentedon Sep 14, 2026
FYI, i find #25259 also fix this issue.
fix: map pushed-down filter columns by position instead of by name (a…
fix: map pushed-down filter columns by position instead of by name (a…
fix: map pushed-down filter columns by position instead of by name (a…
fix: map pushed-down filter columns by position instead of by name (a…