A Multi-Row Subquery is an inner query block that returns a relation of multiple rows and a single column ($N \ge 0, \text{degree} = 1$). Because scalar comparison operators (=, >, <) cannot evaluate multi-element sets, multi-row subqueries require set membership operators (IN, NOT IN) or quantified comparison operators (ANY, SOME, ALL).
IN against quantified operators (> ANY, < ALL).NOT IN with nullable columns.work_mem) and plan node performance across large set evaluations.Multi-row subqueries drive key decision-making workflows in transactional systems:
In relational algebra, a multi-row IN predicate is defined as set membership ($\in$). Given an outer tuple key $r[A]$ and an inner subquery relation $S = \pi_{B}(T)$:
Quantified operators extend scalar comparisons across sets:
A > ANY (S): True if $A$ is strictly greater than at least one element in $S$ ($\equiv A > \min(S)$).A > ALL (S): True if $A$ is strictly greater than every element in $S$ ($\equiv A > \max(S)$).-- ANSI SQL Multi-Row Subquery with IN Operator
SELECT
e.emp_id,
e.emp_name,
e.dept_id
FROM employes e
WHERE e.dept_id IN (
SELECT d.dept_id
FROM departments d
WHERE d.location_id IN (1, 2)
);
-- Quantified ANY Comparison
SELECT
e.emp_id,
e.emp_name,
e.hire_date
FROM employes e
WHERE e.hire_date > ANY (
SELECT e_sub.hire_date
FROM employes e_sub
WHERE e_sub.dept_id = 2
);
Visualizing multi-row set membership:
┌─────────────────────────────────────────────────────────┐
│ Step 1: Subquery Materialization / Hash Table Build │
│ SELECT dept_id FROM departments WHERE location_id IN(1,2)│
│ Inner Set S = { 1, 2, 5 } │
└──────────────────────────┬──────────────────────────────┘
│ Hashed into Memory
▼
┌─────────────────────────────────────────────────────────┐
│ Step 2: Outer Table Scan & Hash Probe │
│ For each outer employee e: │
│ Check if e.dept_id ∈ Hash_Table(S) │
│ Row matched? -> Stream to Output │
└─────────────────────────────────────────────────────────┘
work_mem).Modern optimizers unnest uncorrelated IN subqueries into Hash Semi-Joins ($\ltimes$).
{1, 1, 2, 2}), the optimizer’s semi-join engine ignores duplicates, preventing cardinality inflation on the outer query.Annotated PostgreSQL execution plan for an IN subquery:
Hash Semi Join (cost=1.15..3.45 rows=20 width=36) (actual time=0.035..0.078 rows=18 loops=1)
Hash Cond: (e.dept_id = d.dept_id)
Buffers: shared hit=3
-> Seq Scan on employes e (cost=0.00..2.00 rows=50 width=36) (actual time=0.005..0.015 rows=50 loops=1)
-> Hash (cost=1.10..1.10 rows=4 width=4) (actual time=0.018..0.019 rows=4 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on departments d (cost=0.00..1.10 rows=4 width=4) (actual time=0.007..0.010 rows=4 loops=1)
Filter: (location_id = ANY ('{1,2}'::integer[]))
Hash Semi Join: Demonstrates subquery unnesting.Memory Usage: 9kB: Shows the small memory footprint of the materialized inner hash key set.| Engine | IN Optimization |
NOT IN Optimization |
ANY/ALL Rewrite |
|---|---|---|---|
| PostgreSQL 16+ | Unnests to Hash/Merge Semi Join. | Rewrites to Anti Join if non-nullable; else SubPlan. | Rewrites > ANY to > MIN() or Semi Join. |
| MySQL 8.0+ | Materializes via Materialized_From_Subquery. |
Uses Anti-Join optimization. |
Converts to Subquery Materialization. |
| SQL Server 2022 | Left Semi Join operator in Showplan. | Left Anti Semi Join operator. | Rewritten to scalar aggregates. |
| Oracle 23c | Semi-join transformation (SJ). |
Anti-join transformation (AJ). |
Unnested to inline views. |
NOT IN NULL TrapIf a NOT IN subquery returns even one NULL value, the entire predicate evaluates to UNKNOWN for all outer rows, causing the query to return 0 rows.
-- ❌ DANGEROUS: Returns ZERO ROWS if any department has location_id IS NULL
SELECT emp_name
FROM employes
WHERE dept_id NOT IN (SELECT dept_id FROM departments);
-- ✅ SAFE REWRITE: Filter NULLs explicitly or use NOT EXISTS
SELECT emp_name
FROM employes
WHERE dept_id NOT IN (SELECT dept_id FROM departments WHERE dept_id IS NOT NULL);
When the inner subquery result set exceeds available work_mem, PostgreSQL spills the hash table to disk (Batches $> 1$), causing high temporary file I/O. Tune work_mem for sessions executing heavy set membership subqueries.
IN (1, 2, 3, ... 1000) literal strings in application code. Use array parameters (WHERE col = ANY($1)) or parameterized subqueries to preserve prepared statement execution plan caches.Airbnb filters listings located within active promotional cities:
SELECT
l.listing_id,
l.property_name,
l.nightly_price
FROM listings l
WHERE l.city_id IN (
SELECT c.city_id
FROM market_cities c
WHERE c.region = 'NORTH_AMERICA'
AND c.is_active = TRUE
);
Quantified operators like > ANY are equivalent to scalar comparisons against aggregates:
x > ANY (SELECT y FROM T) $\equiv$ x > (SELECT MIN(y) FROM T)x > ALL (SELECT y FROM T) $\equiv$ x > (SELECT MAX(y) FROM T)Rewriting > ANY to MIN() explicitly allows the optimizer to utilize index min/max scans ($O(\log N)$) rather than unnesting multi-row sets.
NOT EXISTS preferred over NOT IN in production SQL?Answer: NOT EXISTS utilizes 2-valued boolean logic based on tuple count ($>0$ or $=0$) and is completely immune to NULL values in the inner table. NOT IN uses 3-valued logic and will silently evaluate to UNKNOWN (returning 0 rows) if the subquery returns any NULL value.
| Operator | Evaluates To | Best For | NULL Safe? |
|---|---|---|---|
IN |
True if key in set | Positive set matching | Yes |
NOT IN |
True if key not in set | Negative set matching | ❌ NO (Fails on NULL) |
ANY / SOME |
True if matches $\ge 1$ item | Quantified comparisons | Partial |
ALL |
True if matches all items | Global set bounding | Partial |