Repository navigation
Wrong results: NOT IN (subquery) ignores NULLs from a nullable outer expression over NOT NULL columns #25474
Description
Related PR
Problem
If the outer side of NOT IN (subquery) is an expression that can produce
NULL, but every column it references is declared NOT NULL, the decorrelated
join is not null-aware. Rows where the expression is NULL are then returned
instead of being filtered out as UNKNOWN.
Tested with a datafusion-cli built from main at 3b16a3d0ee:
CREATE TABLE o(x INT NOT NULL) AS VALUES (1), (2);
CREATE TABLE on2(x INT) AS VALUES (1), (2); -- same data, nullable column
CREATE TABLE i(id INT NOT NULL) AS VALUES (1), (3);
CREATE TABLE ie(id INT NOT NULL) AS SELECT * FROM (VALUES (1)) WHERE false;For every row below the outer expression evaluates to NULL.
| Query | Expected | Actual |
|---|---|---|
SELECT x FROM o WHERE (CASE WHEN x > 100 THEN 1 END) NOT IN (SELECT id FROM i) |
no rows | 1, 2 ❌ |
SELECT x FROM o WHERE NULLIF(x, x) NOT IN (SELECT id FROM i) |
no rows | 1, 2 ❌ |
SELECT x FROM o WHERE x + CAST(NULL AS INT) NOT IN (SELECT id FROM i) |
no rows | 1, 2 ❌ |
SELECT x FROM o WHERE (CASE WHEN x > 100 THEN 1 END) NOT IN (SELECT id FROM i) OR x = 99 |
no rows | 1, 2 ❌ |
same CASE query over on2 (nullable x) |
no rows | no rows ✅ |
CASE query against empty ie |
1, 2 |
1, 2 ✅ |
SELECT x FROM o WHERE x NOT IN (SELECT CASE WHEN id > 100 THEN id END FROM i) |
no rows | no rows ✅ |
The only difference between the failing CASE query and the on2 control is
the declared nullability of x. The expression evaluates to NULL in both.
The subquery side is already handled correctly (last row). Subquery
expressions are projected into the __correlated_sq_N alias, so their
nullability reaches the schema as a column.
Root cause
join_keys_may_be_null in
datafusion/optimizer/src/decorrelate_predicate_subquery.rs (added in #19635)
decides whether NOT IN needs null-aware semantics by collecting the
columns in the join filter and checking each field's schema nullability.
An outer expression is never projected into a column, so its own nullability is
never considered. Only the nullability of the columns it references is.
Expressions that are nullable over non-nullable inputs include CASE without
ELSE, NULLIF, arithmetic with a NULL operand, and scalar functions that can
return NULL. For all of them, the helper returns false and:
LeftAntipath (line ~639): a plain anti join is built. The physical plan is
HashJoinExec: join_type=RightAnti, on=[(i.id, CASE WHEN ... END)]without
null_aware, so an outer row with a NULL key finds no match and is emitted.LeftMarkpath (line ~603): the mark join is not null-aware, so the mark is
falseinstead ofNULL, andNOT mark OR ...keeps the row.
By code history this bug predates #25348. The helper and both gates come from
#19635. It was not bisected against an older build.
Why it matters
Silent wrong results. The pattern is realistic: CASE, NULLIF and
COALESCE-like expressions over NOT NULL columns are common in NOT IN
predicates, and schemas from Parquet or CREATE TABLE ... NOT NULL often
declare columns non-nullable.
Invariant / desired behavior
For NOT IN (subquery), the decision to use null-aware semantics must be
based on whether each side of the comparison, as an expression, can be
NULL. The nullability of the columns an expression references is not enough.
A nullable expression over non-nullable columns must get the same result as
the same values in a nullable column.
Proposed direction
Unvalidated: not implemented or tested. Confirm with the tests below.
Replace the column walk in join_keys_may_be_null with expression-level
nullability of the IN equality operands:
- For each
a = bconjunct produced from theINpredicate, evaluate
a.nullable(left_schema)andb.nullable(right_schema), choosing the
schema each operand resolves against. - If an operand's nullability can't be determined (for example, it mixes both
schemas), fall back totrue. Treating a key as nullable costs a null-aware
join; treating it as non-nullable gives wrong results.
This is the earliest boundary where the bad decision is made, and both the
LeftAnti and LeftMark gates call it. It should also cover #25473,
because Int32(NULL).nullable(..) is true.
Risks to check while implementing:
- More queries will take the null-aware path. Confirm the physical planner
accepts a null-aware hash join whose left key is a non-column expression. A
non-null-aware join with such a key already plans with aProjectionExec. - Null-aware hash joins accept a single key. Check that correlated
NOT INwith
a nullable outer expression (an equality key plus a correlation key) either
stays correct or is kept off the null-aware path, as it is today for
nullable columns. - Watch for plan changes in existing
EXPLAINSLTs and benchmark queries
(TPC-H Q16 usesNOT IN) where operands become nullable-by-expression.
Scope
In
join_keys_may_be_nulland its callers inbuild_join(theLeftAntiand
LeftMarknull-aware gates).- Uncorrelated
NOT INwith a nullable outer expression overNOT NULL
columns.
Out
- The constant-projection gate for NULL constants (Wrong results: nullable constant
NOT IN (subquery)returns every row when the subquery column isNOT NULL#25473). It is fixed
here only if this change naturally covers it. - Adding null-aware support for multi-key or residual-filter joins.
NOT EXISTS, which uses two-valued logic and is correct as is.
Acceptance criteria
- Every ❌ row in the table above returns the expected result.
- Every ✅ row is unchanged, including the empty-subquery case (
1, 2). -
EXPLAINfor theCASEquery onoshows anull_awareanti join,
matching theon2plan shape. - Queries whose
NOT INoperands are provably non-nullable expressions
(for examplex + 1overNOT NULL x) still plan a non-null-aware join. - No unexplained plan changes in the existing SLT
EXPLAINoutput or the
TPC-H plans.
Tests / verification
- SLT in
datafusion/sqllogictest/test_files/null_aware_anti_join.slt:CASE
withoutELSE,NULLIF(x, x)andx + NULLover aNOT NULLcolumn, each
compared with the same query over a nullable column, plus an empty-subquery
control and anEXPLAINassertion. - SLT in
datafusion/sqllogictest/test_files/null_aware_mark_join.slt: the
... NOT IN (...) OR x = 99form. - Optimizer unit tests in
decorrelate_predicate_subquery.rs: null-aware for a
nullable expression over a non-nullable column, and not null-aware for
x + 1over non-nullablex. cargo test -p datafusion-optimizer,
cargo test -p datafusion-sqllogictest --test sqllogictests, which includes the TPC-H
plan SLTs undertest_files/tpch.
Related
- feat: Add null-aware anti join support #19635: introduced
join_keys_may_be_nulland null-aware anti joins. - Wrong results: nullable constant
NOT IN (subquery)returns every row when the subquery column isNOT NULL#25473: the same helper misses nullable constants. A fix here likely
covers it.
Activity
fix: Decide NOT IN null-aware joins from expression nullability, not …
namanjain24-sudo commentedon Oct 2, 2026
Fixed on main by #25338's null-aware join rework — verified a nullable expression (e.g. CASE without ELSE) over NOT NULL columns now correctly produces NULL for
NOT IN.kosiew commentedon Oct 3, 2026
@namanjain24-sudo
Thanks for pointer.
Adding tests in #25994
Add regression tests for nullable NOT IN expressions (apache#25994)
Add regression tests for nullable NOT IN expressions (apache#25994)