Repository navigation
Improve TableScan with the pushdown filter unparsing in Join #13156
Copy link
Copy link
Open
Labels
enhancementNew feature or requestNew feature or request
Description
Is your feature request related to a problem or challenge?
To produce a better SQL for the performance, we can change the unparsing result for TableScan with pushdown.
See the discussion #13132 (comment)
Describe the solution you'd like
Instead of putting the predicates in the join condition, we can wrap a subquery for the table scan and put the predicate in the WHERE clause. This way can trigger the filter pushdown in any join type.
I did some tests for this pattern:
-----------------inner join-------------------
###### predicate in filter ######
SQL: select o_orderkey from orders inner join (select c_custkey from customer where c_name = 'Customer#000000001') on o_custkey = c_custkey
Projection: orders.o_orderkey
Inner Join: orders.o_custkey = customer.c_custkey
TableScan: orders projection=[o_orderkey, o_custkey]
Projection: customer.c_custkey
Filter: customer.c_name = Utf8("Customer#000000001")
TableScan: customer projection=[c_custkey, c_name], partial_filters=[customer.c_name = Utf8("Customer#000000001")]
-----------------left join-------------------
###### predicate in filter ######
SQL: select o_orderkey from orders left join (select c_custkey from customer where c_name = 'Customer#000000001') on o_custkey = c_custkey
Projection: orders.o_orderkey
Left Join: orders.o_custkey = customer.c_custkey
TableScan: orders projection=[o_orderkey, o_custkey]
Projection: customer.c_custkey
Filter: customer.c_name = Utf8("Customer#000000001")
TableScan: customer projection=[c_custkey, c_name], partial_filters=[customer.c_name = Utf8("Customer#000000001")]
-----------------right join-------------------
###### predicate in filter ######
SQL: select o_orderkey from orders right join (select c_custkey from customer where c_name = 'Customer#000000001') on o_custkey = c_custkey
Projection: orders.o_orderkey
Right Join: orders.o_custkey = customer.c_custkey
TableScan: orders projection=[o_orderkey, o_custkey]
Projection: customer.c_custkey
Filter: customer.c_name = Utf8("Customer#000000001")
TableScan: customer projection=[c_custkey, c_name], partial_filters=[customer.c_name = Utf8("Customer#000000001")]
-----------------full join-------------------
###### predicate in filter ######
SQL: select o_orderkey from orders full join (select c_custkey from customer where c_name = 'Customer#000000001') on o_custkey = c_custkey
Projection: orders.o_orderkey
Full Join: orders.o_custkey = customer.c_custkey
TableScan: orders projection=[o_orderkey, o_custkey]
Projection: customer.c_custkey
Filter: customer.c_name = Utf8("Customer#000000001")
TableScan: customer projection=[c_custkey, c_name], partial_filters=[customer.c_name = Utf8("Customer#000000001")]
We can see the filter pushdown works well.
Describe alternatives you've considered
No response
Additional context
No response
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request
Activity
feat(unparser): Keep inner join `Filter → TableScan` predicates to `W…
greg-semvia commentedon Aug 5, 2026
This is filed as a performance improvement, but for outer joins it is also a correctness bug, and I think that is worth recording here since the issue is the tracking point for the fix.
Folding a filter into
ONis equivalence-preserving only when the filtered input is not a preserved side of the join. On a preserved side the two placements mean different things: below the join the predicate restricts the input, inONit only gates match eligibility, so every row that fails it survives NULL-padded instead of disappearing. ForLEFT JOINthat is the left input, forRIGHT JOINthe right, and forFULL JOINboth. TheINNER JOINcase in the issue body is sound, and the non-preserved side of an outer join is sound too.#21694 fixed the inner-join half and states that "outer joins require additional work" and that for LEFT/RIGHT/FULL "the existing behavior is preserved" — so the case below is still live on 53.1.0 and on
main(I checked the source; the most recent change todatafusion/sql/src/unparser/plan.rsis unrelated).Repro
Standalone, no external data.
datafusion = "53.1.0",tokio = { version = "1", features = ["macros", "rt-multi-thread"] }.Output:
The plan and its unparsing disagree on the answer, not on the shape.
Two things narrow it further. The plan here is unoptimized — a plain
Filternode above the scan, with nothing folded intoTableScan.filters— so the defect is not confined to the pushdown path the issue title names, and there is no plan-preparation precondition that avoids it. And executing the sameLogicalPlandirectly returns the right answer, so this is purely SQL generation.The proposed fix also fixes this
Wrapping the preserved side in a
SubqueryAliasproduces the derived table this issue asks for, and with it the correct row count:So the derived-table rendering is not only the better plan for the source, it is the only correct one when the filtered input is preserved. A narrower version of the same fix — fold into
ONonly when the filtered input is not a preserved side of the join type, and emit a derived table otherwise — would close the correctness half without changing inner-join output at all.Why the row counts matter
I hit this generating a single SQL statement from a plan whose
LEFT JOINpreserved side carried a ground equality. On the real source the hoisted form returned 482,824 rows — the full extent of the preserved relation — where the correct answer is 219. Nothing errors and nothing looks malformed; the result set is well-typed, plausibly shaped, and wrong by three orders of magnitude. That failure mode is why I would rather see this treated as a correctness issue than a performance one: a slow query announces itself, and this does not.We route around it locally by emitting the preserved side as an explicit
SubqueryAlias, as above, so this is not blocking on our end — posting the repro in case it is useful to whoever picks up the outer-join half.