Hi,

383eb21eb converts a NOT IN to an anti-join once it can prove both the outer
expressions and the sub-select's output columns non-null.  The second proof goes
wrong when an output column is an upper-level Var, and the query then keeps a
row it should have dropped.  Column order matters here -- o.b and i.x have to
land on the same attnum:

    \pset null '<NULL>'
    CREATE TABLE o (b int, a int NOT NULL);
    CREATE TABLE i (x int);
    INSERT INTO o VALUES (NULL, 1);
    INSERT INTO i VALUES (5);

    SELECT * FROM o WHERE o.a NOT IN (SELECT o.b FROM i WHERE i.x IS NOT NULL);
       b    | a
    --------+---
     <NULL> | 1
    (1 row)

The sub-select returns one row and its value is NULL, so the NOT IN is NULL and
that row should not come back:

    SELECT o.a, o.b,
           (SELECT count(*) FROM i WHERE i.x IS NOT NULL) AS subquery_rows,
           (o.a NOT IN (SELECT o.b FROM i WHERE i.x IS NOT NULL)) AS
not_in_value
    FROM o;
     a |   b    | subquery_rows | not_in_value
    ---+--------+---------------+--------------
     1 | <NULL> |             1 | <NULL>
    (1 row)

and writing the same predicate so that it can't be pulled up agrees:

    SELECT * FROM o
    WHERE (o.a NOT IN (SELECT o.b FROM i WHERE i.x IS NOT NULL)) OR false;
     b | a
    ---+---
    (0 rows)

EXPLAIN (COSTS OFF) on the first query gets

     Nested Loop Anti Join
       Join Filter: (o.a = o.b)
       ->  Seq Scan on o
       ->  Materialize
             ->  Seq Scan on i
                   Filter: (x IS NOT NULL)

When expr_is_nonnullable() can't prove an output column non-null,
query_outputs_are_not_nullable() falls back to find_nonnullable_vars() over the
quals find_subquery_safe_quals() collected.  find_nonnullable_vars() only
reports Vars of the current level, and the multibitmapset it returns identifies
them by varno and varattno alone, so the upper-level o.b -- varno 1, varattno 1
in the outer rangetable -- matches i.x, varno 1, varattno 1 in the sub-select's.
var_is_nonnullable() turns upper-level Vars away; this fallback doesn't:

- if (IsA(expr, Var))
+ if (IsA(expr, Var) && ((Var *) expr)->varlevelsup == 0)

There is nothing else to try for an upper-level Var there anyway, since
query_outputs_are_not_nullable() only has the sub-Query to work with.  Patch
attached, with a test; the test fails without the one-line change, and make
check is green with it.

383eb21eb is in 19beta1, so this wants fixing on that branch too.

The same weakness has a second instance in the follow-up patch on [1], which
adds an outer-side proof with its own find_nonnullable_vars() lookup; Ayush
reported that one there [2].  reduce_outer_joins() also calls
find_nonnullable_vars(), but it only compares two such sets, both built at the
same level, so it isn't affected -- those two are the places that match a single
Var against the set.

[1] 
https://postgr.es/m/CAMbWs4_TNUs1jn7q0J-%3DEsz7ziiFdjDAtW4x2u6tv6H5hhmhDA%40mail.gmail.com
[2] 
https://postgr.es/m/CAJTYsWWH6Pm2xiq%3DOM3vnBEBz5%3DJp93G5_VUSKKGdkXinbXzZw%40mail.gmail.com

Thanks,
Rui
From 1f22b4aa9dfa0fa930eb170b9459bf8e0d6f77af Mon Sep 17 00:00:00 2001
From: Rui Zhao <[email protected]>
Date: Fri, 31 Jul 2026 11:10:44 +0800
Subject: Don't prove a sub-select's upper-level output Var non-nullable from a 
qual

query_outputs_are_not_nullable() falls back to find_nonnullable_vars() over
the sub-select's safe quals when expr_is_nonnullable() cannot prove an
output column non-null.  find_nonnullable_vars() only reports Vars of the
current query level, and the multibitmapset it returns identifies them by
varno and varattno alone, so an output column that is an upper-level Var
gets matched against a local Var of an unrelated relation that happens to
have the same varno and varattno.

The sub-select is then wrongly taken to produce no NULLs, a NOT IN over it
is converted to an anti-join, and rows the NOT IN should have discarded
come back:

    CREATE TABLE o (b int, a int NOT NULL);
    CREATE TABLE i (x int);
    INSERT INTO o VALUES (NULL, 1);
    INSERT INTO i VALUES (5);

    SELECT * FROM o WHERE o.a NOT IN (SELECT o.b FROM i WHERE i.x IS NOT NULL);
     b | a
    ---+---
       | 1
    (1 row)

The sub-select returns a single NULL row, so the NOT IN is NULL and the row
should not be returned; spelling the same predicate so that it cannot be
pulled up gives no rows.

Restrict the qual-based proof to Vars of the sub-select's own level.
expr_is_nonnullable() already declines upper-level Vars, and we have no
access to the outer query here, so there is nothing else to try for them.

Introduced by 383eb21eb.
---
 src/backend/optimizer/util/clauses.c    | 19 ++++++++++++------
 src/test/regress/expected/subselect.out | 26 +++++++++++++++++++++++++
 src/test/regress/sql/subselect.sql      | 14 +++++++++++++
 3 files changed, 53 insertions(+), 6 deletions(-)

diff --git a/src/backend/optimizer/util/clauses.c 
b/src/backend/optimizer/util/clauses.c
index 7d7f2f9664..219e8651a3 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -2163,16 +2163,23 @@ query_outputs_are_not_nullable(Query *query)
                if (expr_is_nonnullable(&subroot, expr, NOTNULL_SOURCE_CATALOG))
                        continue;
 
-               if (IsA(expr, Var))
+               if (IsA(expr, Var) && ((Var *) expr)->varlevelsup == 0)
                {
                        Var                *var = (Var *) expr;
 
                        /*
-                        * For a plain Var, even if that didn't work, we can 
conclude that
-                        * the Var is not nullable if find_nonnullable_vars can 
find a
-                        * "var IS NOT NULL" or similarly strict condition 
among the quals
-                        * on non-outerjoined-rels.  Compute the list of Vars 
having such
-                        * quals if we didn't already.
+                        * For a plain Var of this query level, even if that 
didn't work,
+                        * we can conclude that the Var is not nullable if
+                        * find_nonnullable_vars can find a "var IS NOT NULL" 
or similarly
+                        * strict condition among the quals on 
non-outerjoined-rels.
+                        * Compute the list of Vars having such quals if we 
didn't
+                        * already.
+                        *
+                        * Upper-level Vars must be excluded: 
find_nonnullable_vars only
+                        * reports Vars of this level, and identifies them by 
varno and
+                        * varattno alone, so an upper-level Var would be 
matched against
+                        * a local Var of an unrelated relation that happens to 
have the
+                        * same varno and varattno.
                         */
                        if (!computed_nonnullable_vars)
                        {
diff --git a/src/test/regress/expected/subselect.out 
b/src/test/regress/expected/subselect.out
index e7ff719108..fbb7962394 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -3818,6 +3818,32 @@ ON t2.id NOT IN (SELECT id FROM not_null_tab);
                      ->  Seq Scan on not_null_tab
 (8 rows)
 
+-- No ANTI JOIN: the sub-select's output is an upper-level Var, which a qual
+-- on a local Var must not prove non-nullable.  The column order matters here:
+-- corr_outer.b and corr_inner.x must have the same attnum for the two to be
+-- confusable.
+CREATE TEMP TABLE corr_outer (b int, a int NOT NULL);
+CREATE TEMP TABLE corr_inner (x int);
+INSERT INTO corr_outer VALUES (NULL, 1);
+INSERT INTO corr_inner VALUES (5);
+EXPLAIN (COSTS OFF)
+SELECT * FROM corr_outer
+WHERE a NOT IN (SELECT corr_outer.b FROM corr_inner WHERE corr_inner.x IS NOT 
NULL);
+                    QUERY PLAN                    
+--------------------------------------------------
+ Seq Scan on corr_outer
+   Filter: (NOT (ANY (a = (SubPlan any_1).col1)))
+   SubPlan any_1
+     ->  Seq Scan on corr_inner
+           Filter: (x IS NOT NULL)
+(5 rows)
+
+SELECT * FROM corr_outer
+WHERE a NOT IN (SELECT corr_outer.b FROM corr_inner WHERE corr_inner.x IS NOT 
NULL);
+ b | a 
+---+---
+(0 rows)
+
 -- ANTI JOIN: both sides are defined NOT NULL
 EXPLAIN (COSTS OFF)
 SELECT * FROM not_null_tab
diff --git a/src/test/regress/sql/subselect.sql 
b/src/test/regress/sql/subselect.sql
index 76bce5cdba..32b1d39f85 100644
--- a/src/test/regress/sql/subselect.sql
+++ b/src/test/regress/sql/subselect.sql
@@ -1671,6 +1671,20 @@ SELECT * FROM not_null_tab t1
 LEFT JOIN not_null_tab t2
 ON t2.id NOT IN (SELECT id FROM not_null_tab);
 
+-- No ANTI JOIN: the sub-select's output is an upper-level Var, which a qual
+-- on a local Var must not prove non-nullable.  The column order matters here:
+-- corr_outer.b and corr_inner.x must have the same attnum for the two to be
+-- confusable.
+CREATE TEMP TABLE corr_outer (b int, a int NOT NULL);
+CREATE TEMP TABLE corr_inner (x int);
+INSERT INTO corr_outer VALUES (NULL, 1);
+INSERT INTO corr_inner VALUES (5);
+EXPLAIN (COSTS OFF)
+SELECT * FROM corr_outer
+WHERE a NOT IN (SELECT corr_outer.b FROM corr_inner WHERE corr_inner.x IS NOT 
NULL);
+SELECT * FROM corr_outer
+WHERE a NOT IN (SELECT corr_outer.b FROM corr_inner WHERE corr_inner.x IS NOT 
NULL);
+
 -- ANTI JOIN: both sides are defined NOT NULL
 EXPLAIN (COSTS OFF)
 SELECT * FROM not_null_tab
-- 
2.43.7

Reply via email to