Skip to content

fix: account for empty scalar subqueries in nullability - #24516

Merged
neilconway merged 2 commits into
apache:mainfrom
fornwall:nullability-from-subquery
Aug 20, 2026
Merged

fix: account for empty scalar subqueries in nullability#24516
neilconway merged 2 commits into
apache:mainfrom
fornwall:nullability-from-subquery

Conversation

@fornwall

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A scalar subquery that returns zero rows evaluates to NULL, even when its projected expression is non-nullable. DataFusion currently derives Expr::ScalarSubquery nullability from the subquery's output field. This can produce a non-nullable output schema containing NULL, and can cause SimplifyExpressions to incorrectly fold predicates such as:

SELECT (SELECT 1 WHERE FALSE) IS NULL;

to false.

This change deliberately marks all scalar subqueries nullable, including those guaranteed to return exactly one row (such as an ungrouped aggregate like (SELECT count(*) FROM t)). This is a conservative trade-off that gives up some nullability precision for correctness, and is consistent with how PostgreSQL treats scalar subqueries.

A possible follow-up refinement is a LogicalPlan::min_rows() lower bound (mirroring the existing max_rows()), which would let uncorrelated scalar subqueries provably returning at least one row keep their projected field's nullability.

What changes are included in this PR?

Scalar subqueries are conservatively marked nullable in logical expression schema derivation and physical expression planning. The projected field's data type, name, and metadata are preserved.

Are these changes tested?

Yes. Unit tests cover logical schema derivation and expression simplification, and SQLLogicTests cover both zero-row execution and IS NULL correctness.

The full workspace test suite and Clippy with warnings denied pass.

Are there any user-facing changes?

Yes. Zero-row scalar subqueries with non-nullable projections now return NULL without a schema validation error, and IS NULL predicates produce the correct result.

In addition, output schemas containing scalar subqueries now always mark those fields as nullable, even for subqueries that can never produce NULL. Downstream consumers that inspect schema nullability can observe this change. No public APIs change.


AI usage: Created with Claude Code and Opus 5. I have reviewed the code and made modifications where it made sense.

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
@github-actions github-actions Bot added logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Aug 20, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.29%. Comparing base (5091b42) to head (c74d149).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24516      +/-   ##
==========================================
+ Coverage   81.27%   81.29%   +0.01%     
==========================================
  Files        1116     1116              
  Lines      395073   395490     +417     
  Branches   395073   395490     +417     
==========================================
+ Hits       321102   321501     +399     
- Misses      55176    55179       +3     
- Partials    18795    18810      +15     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@neilconway neilconway left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- this is a well-implemented PR. The root problem is clearly described and the fix is minimal and seems correct to me. Good test coverage; we could add coverage for correlated subqueries as well but I think it's probably okay as-is.

Would be great if you're interested in taking on the min_rows refinement as a followup PR.

@neilconway
neilconway added this pull request to the merge queue Aug 20, 2026
Merged via the queue into apache:main with commit 4798476 Aug 20, 2026
40 checks passed
@fornwall

Copy link
Copy Markdown
Contributor Author

Would be great if you're interested in taking on the min_rows refinement as a followup PR.

👍 Created #24534 for that.

@fornwall
fornwall deleted the nullability-from-subquery branch August 20, 2026 19:30
Dandandan pushed a commit to emilk/datafusion that referenced this pull request Aug 21, 2026
## Which issue does this PR close?

- Follow-up to apache#24516, which fixed
apache#24513. The follow-up was suggested during review of
that PR.

## Rationale for this change

apache#24516 conservatively made every scalar subquery
nullable because an empty result evaluates to `NULL`. Plans guaranteed
to return at least one row, such as ungrouped aggregates, cannot produce
that empty-result `NULL`; marking them nullable loses schema precision
and prevents valid expression simplification.

## What changes are included in this PR?

- Adds a conservative `LogicalPlan::min_rows()` lower bound.
- Uses the lower bound in logical schema derivation. Physical
scalar-subquery planning now defers to `Expr::nullable` instead of
hardcoding `nullable = true`, so the logical rule is the single source
of truth.
- Makes `max_rows()` account for the one-row form of `EmptyRelation`,
keeping both bounds consistent.

## Are these changes tested?

Yes. Unit tests cover row bounds, logical schema derivation, optimizer
simplification, and physical expression nullability for both possibly
empty and guaranteed non-empty scalar subqueries. A sqllogictest pins
the user-visible effect: `EXPLAIN` shows that `(SELECT count(*) FROM
empty_table) IS NULL` now folds to `false`, eliminating the subquery
from the plan. (Query results alone cannot show the change: a guaranteed
non-empty scalar subquery never evaluates to `NULL` at runtime.)

As an ablation check, restoring the conservative always-nullable
behavior makes the logical, optimizer, physical, and sqllogictest tests
fail.

The required all-feature Clippy check and extended workspace test suite
pass.

## Are there any user-facing changes?

Yes. Scalar subqueries proven to return at least one row now preserve
the nullability of their projected field, allowing valid simplification.
Potentially empty scalar subqueries remain nullable.
`LogicalPlan::min_rows()` is a new public API.

---

AI usage: Implemented with Codex; reviewed and test coverage extended
with Claude Code. I reviewed the code and made modifications where
appropriate.

---------

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

logical-expr Logical plan and expressions optimizer Optimizer rules physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scalar subquery returning no rows is incorrectly marked non-nullable

3 participants