Nested Subqueries
Suppose a university database must answer: "which students have taken every course offered by the Comp. Sci. department?" No single-table filter answers this. It demands comparing each student against an entire set of courses. That set must itself be computed from the database. SQL solves this class of problem by allowing one query to be written inside another — a nested subquery. This way, the inner query's result becomes a value, a set, or a table. The outer query then tests against that result. Nested subqueries matter because they let SQL express relational-calculus-style reasoning ("for all", "there exists", "not in any"). This reasoning happens directly inside a `WHERE` or `FROM` clause. It avoids forcing the programmer to manually stage intermediate results in temporary tables. Virtually every non-trivial reporting query leans on this construct. Every anti-join ("customers who never ordered") relies on it too. So does every "top-N per group" query in production systems. Understanding subqueries is also a gateway to understanding the query optimizer. The same SQL can be executed as a nested-loop re-evaluation. Or it can be rewritten into a join instead. That difference separates a query running in milliseconds from one that times out. The big picture is that a subquery is just a query. SQL is closed under composition. Anywhere a value, a set of values, or a table is expected, another `SELECT` can appear instead. The subtlety is correlation — whether the inner query depends on the outer query's current row. Correlation changes both the semantics and the naive execution cost. The inner query is re-evaluated per outer row when correlation is present. This article gives a precise semantics for scalar, `IN`/`NOT IN`, `EXISTS`/`NOT EXISTS`, quantified (`ALL`/`ANY`), and derived-table subqueries. It provides a step-by-step evaluation algorithm with pseudocode. It also includes a correctness and complexity analysis, covering the classic NULL pitfalls. A fully worked trace is included as well. Finally, it maps where these ideas surface in real systems. It also connects to later material on Query Optimization Fundamentals.
- A nested subquery is a `SELECT` embedded inside another query's `WHERE`, `FROM`, `HAVING`, or `SELECT` clause.
- Uncorrelated subqueries are computed once; correlated subqueries are conceptually re-evaluated per outer row.
- `IN`/`NOT IN`, `EXISTS`/`NOT EXISTS`, and `= ANY`/`ALL` give four related but semantically distinct membership/quantifier tests.
- `NOT IN` silently returns no rows if the subquery result contains a `NULL` — the single most dangerous trap in SQL.
- A subquery in `FROM` (a derived table) lets you treat any query result as a table, feeding Joins in SQL and Aggregation and the GROUP BY Clause.
- Optimizers routinely rewrite correlated subqueries into semi-joins/anti-joins, turning an naive plan into an hash- or sort-based plan.
A subquery is an ordinary query used as a value, a set, or a table — correlation is what makes it conceptually a function of the outer row rather than a constant.
The idea
A nested subquery is a `SELECT` statement embedded within another SQL statement, appearing wherever the surrounding syntax expects a value, a set of values, or a table. The enclosing statement is the outer query; the embedded one is the inner query or subquery. Subqueries can nest inside `WHERE` clauses (the most common case), inside `FROM` clauses (called derived tables or inline views), inside `HAVING` clauses, and even inside the `SELECT` list as a scalar subquery. The crucial classification is correlation. An uncorrelated subquery contains no reference to any column of the outer query; it can be evaluated exactly once, independently, and its result (a scalar, a set, or a table) is then used by the outer query as if it were a constant. A correlated subquery references at least one column from the enclosing query — for example `WHERE EXISTS (SELECT 1 FROM Enrolled E WHERE E.ID = S.ID)` inside a query over `Student S`. Semantically, a correlated subquery is a function of the current outer row: each candidate outer row induces a different inner-query result, so the inner query must, at least conceptually, be re-evaluated once per outer row. This is precisely analogous to a closure capturing a free variable from an enclosing scope in a programming language. SQL provides several predicate forms for consuming a subquery's result: `IN` / `NOT IN` (set membership), `EXISTS` / `NOT EXISTS` (non-emptiness test), `= ANY` / `<> ALL` and friends (quantified comparison), and bare scalar comparison (`=`, `<`, etc., valid only when the subquery is guaranteed to return at most one row). Each of these has precise, three-valued (`TRUE`/`FALSE`/`UNKNOWN`) semantics under SQL's `NULL` handling, and the differences between them — especially around `NULL` — are a frequent source of subtle bugs, discussed below.
How it works
Evaluating a scalar subquery. A subquery used where a single value is expected (e.g., `WHERE salary > (SELECT AVG(salary) FROM Employee)`) must produce exactly one row and one column; if it produces zero rows the result is `NULL`, and if it produces more than one row the query raises a runtime error (a cardinality violation). Conceptually: ``` function EVAL_SCALAR_SUBQUERY(inner_query, outer_row, correlation_vars): bind correlation_vars using outer_row // no-op if uncorrelated result_rows := EXECUTE(inner_query) if COUNT(result_rows) == 0: return NULL if COUNT(result_rows) > 1: raise ERROR "subquery returned more than one row" return result_rows[0].the_single_column ```
Evaluating `IN` / `NOT IN`. Here the inner query yields a set of values (nulls possibly included). ``` function EVAL_IN(expr, inner_query, outer_row): bind correlation_vars using outer_row V := { row.col for row in EXECUTE(inner_query) } if expr in V: return TRUE else if NULL in V: return UNKNOWN // NOT IN over this V will also be UNKNOWN else: return FALSE function EVAL_NOT_IN(expr, inner_query, outer_row): return NOT( EVAL_IN(expr, inner_query, outer_row) ) // three-valued NOT ``` The three-valued `NOT` is the source of the famous `NOT IN` pitfall: `NOT(UNKNOWN) = UNKNOWN`, and a row is kept only when its predicate evaluates to `TRUE` — `UNKNOWN` rows are discarded, exactly like `FALSE` rows.
Evaluating `EXISTS` / `NOT EXISTS`. This is purely a cardinality test, immune to the `NULL`-poisoning problem above (it asks "is the row set nonempty", not "is my value among these values, some of which might be unknown"). ``` function EVAL_EXISTS(inner_query, outer_row): bind correlation_vars using outer_row result_rows := EXECUTE(inner_query) return COUNT(result_rows) > 0 // strictly TRUE or FALSE, never UNKNOWN function EVAL_NOT_EXISTS(inner_query, outer_row): return NOT EVAL_EXISTS(inner_query, outer_row) ```
Evaluating quantified comparisons (`ANY`/`ALL`). `x = ANY (subquery)` is equivalent to `x IN (subquery)`; `x <> ALL (subquery)` is equivalent to `x NOT IN (subquery)`. More generally `x op ANY (Q)` is true if `x op v` is true for some row `v` of `Q`, and `x op ALL (Q)` is true if `x op v` holds for every row `v` of `Q` (vacuously true if `Q` is empty). ``` function EVAL_ALL(x, op, inner_query, outer_row): bind correlation_vars using outer_row V := { row.col for row in EXECUTE(inner_query) } if V is empty: return TRUE // vacuous truth saw_unknown := FALSE for v in V: r := APPLY(op, x, v) // TRUE / FALSE / UNKNOWN (NULL involved) if r == FALSE: return FALSE if r == UNKNOWN: saw_unknown := TRUE return UNKNOWN if saw_unknown else TRUE ```
Overall outer-query evaluation. The naive but semantically faithful execution strategy for a `WHERE`-clause correlated subquery is nested-loop style, one inner evaluation per outer candidate row: ``` function EVALUATE_OUTER_QUERY(OuterFrom, OuterPred, Subquery): output := empty set for row in OuterFrom: // one row at a time truth := EVAL_PREDICATE(OuterPred, row, Subquery) // may itself invoke // EVAL_EXISTS / EVAL_IN / ... if truth == TRUE: output.add(PROJECT(row)) return output ```
Derived tables. A subquery in `FROM` is materialized (or streamed) as an ordinary table with its own schema, then joined, filtered, or aggregated exactly like any base table: ``` SELECT D.dept_name, D.avg_sal FROM (SELECT dept_name, AVG(salary) AS avg_sal FROM Employee GROUP BY dept_name) AS D WHERE D.avg_sal > 50000 ``` This turns any query result into an input for further Joins in SQL or Aggregation and the GROUP BY Clause, which is why derived tables are the natural stepping stone to full Query Optimization Fundamentals — a correlated `WHERE`-subquery and a derived-table join can express the same answer set, and the optimizer's job is to prove that rewrite is safe.
Complexity & correctness
Correctness argument. The semantics above are defined so that a query with a subquery is equivalent to its unfolding in relational calculus. For a correlated `EXISTS` subquery, `WHERE EXISTS (Q(outer_row))` is true for an outer row exactly when the relational-calculus formula holds — this is a direct restatement of the existential quantifier, so `EXISTS` correctly implements "there exists a matching inner tuple." Symmetrically, `NOT EXISTS (Q1) AND NOT EXISTS (Q2)` composed twice is the standard idiom for relational-calculus universal quantification ("for all"): to test "student has taken every course ", rewrite as , i.e. ``` SELECT S.name FROM Student S WHERE NOT EXISTS ( SELECT C.course_id FROM Course C WHERE NOT EXISTS ( SELECT 1 FROM Enrolled E WHERE E.ID = S.ID AND E.course_id = C.course_id ) ) ``` This double-negation pattern is provably correct because it is literally the De Morgan translation applied at the level of tuples, not an ad hoc trick — any course for which the inner `NOT EXISTS` is true is a counterexample course the student hasn't taken, and the outer `NOT EXISTS` says no counterexample exists. For `IN`/`NOT IN`/`ANY`/`ALL`, correctness reduces to showing `EVAL_IN` above matches the SQL standard's three-valued logic table for membership, and that `NOT` is applied under three-valued logic (`NOT TRUE = FALSE`, `NOT FALSE = TRUE`, `NOT UNKNOWN = UNKNOWN`), and that a `WHERE` clause keeps a row iff its predicate is exactly `TRUE` (both `FALSE` and `UNKNOWN` reject the row). This last rule is what produces the `NOT IN` pitfall discussed below, and it is not a bug — it follows deterministically from the standard's definition of `WHERE`.
Complexity. Let the outer query range over rows and the (uncorrelated part of the) inner query range over rows.
- Uncorrelated subquery: computed once, cost , then reused for all outer rows — typically total if the result is materialized into a hash set or sorted array for or membership tests per outer row, giving with sorting or expected with hashing.
- Correlated subquery, naive nested-loop evaluation: the inner query genuinely depends on the outer row, so a literal implementation re-executes it times, each costing up to (a full scan or an indexed lookup at ) — total naively, or with an index on the correlation column.
- Optimizer rewrite: because many correlated subqueries (especially `EXISTS`/`IN`/`NOT EXISTS`/`NOT IN` used as filters) are semantically equivalent to a semi-join or anti-join, a real optimizer rewrites them into a join executed via hashing or sort-merge, achieving (hash join) or (sort-merge join) instead of . This rewrite is why understanding subqueries is a direct prerequisite to Query Optimization Fundamentals: the SQL text does not dictate the execution plan, only the required output.
- Space: an uncorrelated subquery's result must be held (as a set, hash table, or sorted list) in space; a correlated subquery evaluated naively needs no extra buffering beyond one inner result at a time, i.e. extra space per outer row (though a rewritten join plan may need for a hash table).
The honest tradeoff: correlated subqueries are the most expressive and the easiest to write correctly, but are the easiest to write inefficiently, since a naive engine re-executes the inner query per row. Modern relational engines almost always attempt the join rewrite, but ORMs and hand-written SQL against weaker engines can still hit the path — this is why "avoid correlated subqueries in hot paths" persists as folklore advice, even though it is really "avoid correlated subqueries the optimizer can't decorrelate."
Worked example
Consider a simplified university schema: ``` Student(ID, name) Course(course_id, title) Enrolled(ID, course_id) ``` Data:
| Student | Course | Enrolled | |||
|---|---|---|---|---|---|
| ID | name | course_id | title | ID | course_id |
| 1 | Amy | CS101 | Intro | 1 | CS101 |
| 2 | Bo | CS202 | Algo | 1 | CS202 |
| 3 | Cy | — | — | 2 | CS101 |
Query: find students who have taken every course. ``` SELECT S.name FROM Student S WHERE NOT EXISTS ( SELECT C.course_id FROM Course C WHERE NOT EXISTS ( SELECT 1 FROM Enrolled E WHERE E.ID = S.ID AND E.course_id = C.course_id ) ) ``` Trace the nested-loop evaluation exactly as `EVALUATE_OUTER_QUERY` would:
| Outer row (S) | Inner loop over Course C | Inner-inner: Enrolled rows for (S.ID, C.course_id) | `NOT EXISTS(inner-inner)` | Outer `NOT EXISTS` over C |
|---|---|---|---|---|
| Amy (ID=1) | CS101 | (1,CS101) found → exists=TRUE | FALSE | — |
| Amy (ID=1) | CS202 | (1,CS202) found → exists=TRUE | FALSE | — |
| no counterexample course found | TRUE → keep Amy | |||
| Bo (ID=2) | CS101 | (2,CS101) found → exists=TRUE | FALSE | — |
| Bo (ID=2) | CS202 | none found → exists=FALSE | TRUE (counterexample!) | FALSE → drop Bo |
| Cy (ID=3) | CS101 | none found | TRUE (counterexample) | FALSE → drop Cy |
Result: `{Amy}` — exactly the student enrolled in both `CS101` and `CS202`. The trace shows the double-negation mechanism concretely: Bo is rejected the moment a single course (`CS202`) is found for which no `Enrolled` row exists, which is precisely the counterexample that falsifies "Bo has taken every course."
Second case — the `NOT IN` NULL trap, using the same `Enrolled` table but suppose one row has a `NULL` course_id (e.g., a withdrawn enrollment recorded as `(2, NULL)`): ``` SELECT name FROM Student WHERE ID NOT IN (SELECT ID FROM Enrolled WHERE course_id IS NULL OR TRUE) ``` More precisely, consider the classic form: ``` SELECT S.name FROM Student S WHERE S.ID NOT IN (SELECT E.ID FROM Enrolled E WHERE E.course_id = 'CS202') ``` if the subquery's result set (the set of IDs who took CS202) happens to include a `NULL` ID value (e.g., a malformed row `(NULL, 'CS202')`), then . For Cy (ID=3): `EVAL_IN` checks `3 in {1, NULL}` → not literally present, but `NULL ∈ V` → returns `UNKNOWN`; then `NOT IN` = `NOT(UNKNOWN)` = `UNKNOWN`; the `WHERE` clause discards Cy even though intuitively "Cy never took CS202." This happens for every row once a single `NULL` contaminates — the entire `NOT IN` becomes vacuously empty. Replacing `NOT IN` with `NOT EXISTS` (a pure cardinality test, never poisoned by `NULL` values) restores the intended anti-join semantics.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| `NOT IN` with a `NULL`-containing subquery returns zero rows | Three-valued logic: `NOT(UNKNOWN) = UNKNOWN`, and `WHERE` rejects non-`TRUE` | Prefer `NOT EXISTS`, or filter `WHERE col IS NOT NULL` inside the subquery |
| Scalar subquery returns more than one row | Assuming a "the" value exists when the relationship is one-to-many | Aggregate (`MAX`, `AVG`) or add a `LIMIT`/uniqueness guarantee |
| Forgetting correlation, writing a subquery that (accidentally) doesn't reference the outer row | Copy-paste from a correlated template without updating the join condition | Double-check every subquery references the intended outer alias |
| Treating `EXISTS` and `IN` as always interchangeable | They agree when no `NULL`s are involved, but diverge under `NULL`, and performance characteristics differ | Use `EXISTS`/`NOT EXISTS` for anti/semi-join intent; reserve `IN` for small clean literal sets |
| Assuming the outer query executes the subquery literally once per row in a real engine | Confusing the semantics (per-row re-evaluation) with the execution plan (often rewritten into a join) | Read the `EXPLAIN` plan; don't hand-optimize based on syntax alone |
| Off-by-one in "every"/"for all" double-negation, e.g. forgetting the correlation condition in the innermost `EXISTS` | The inner-inner `EXISTS` must correlate on both the student and the course, or it silently degenerates to a simpler (wrong) predicate | Verify each nested level's correlation columns against the schema explicitly |
Pioneers & history
The relational model and its calculus-based query languages, which nested subqueries directly implement, trace back to Codd's relational algebra and calculus, with SQL itself (originally SEQUEL) designed at IBM to make that calculus usable by non-mathematicians.
Origins · 1 minEdgar F. Codd and how this was discovered
Codd formulated the relational model in 1970 and, crucially, the relational calculus — a declarative, first-order-logic style query language over relations. Nested subqueries with `EXISTS`/`NOT EXISTS` are a direct syntactic realization of existential and universal quantification from that calculus, letting SQL programmers write "for all" and "there exists" queries without hand-coding loops.
Where this matters
Nested subqueries underlie almost every non-trivial analytical or transactional query written in production systems: reporting dashboards that compute "customers above the regional average" use scalar subqueries; fraud-detection and recommendation pipelines use correlated `EXISTS`/`NOT EXISTS` for anti-join patterns like "users who registered but never purchased"; data warehouses built on engines such as PostgreSQL, Oracle, SQL Server, Snowflake, and BigQuery rely on cost-based optimizers whose central job is deciding whether to decorrelate a subquery into a semi-join or anti-join versus executing it as written. ORMs (object-relational mappers) frequently generate correlated subqueries automatically when translating object-graph queries, making an understanding of correlation cost essential for diagnosing slow application queries. The "for all" double-negation idiom appears throughout constraint-checking logic (e.g., verifying referential completeness, checking that every order line references a valid product) and is a standard interview and exam pattern precisely because it exercises quantifier reasoning that later resurfaces in Set Operations in SQL (where `EXCEPT` gives an alternative, often clearer, way to express the same "for all" queries) and in the cost-based rewriting rules central to Query Optimization Fundamentals.
Ask the tutor: How would you rewrite a correlated `NOT EXISTS` "for all" query into an equivalent query using `GROUP BY`/`HAVING COUNT(*)` and a derived table, and when might the optimizer prefer one form over the other?
What exactly makes a subquery "correlated" versus "uncorrelated"?
A subquery is correlated if it references at least one column from an enclosing (outer) query — for example `WHERE E.ID = S.ID` inside a subquery, where `S` is the outer table alias. Because that reference changes value for every outer row, the inner query's result is not a fixed constant but a function of the current outer row, so it must conceptually be re-evaluated per row. An uncorrelated subquery has no such reference: it can be computed exactly once, independently of the outer query, and its single result reused for every outer row, which is why uncorrelated subqueries are generally cheaper to evaluate naively.
Why does `NOT IN` behave so differently from `NOT EXISTS` when NULLs are involved?
`IN`/`NOT IN` compare a value against a set of values using SQL's three-valued logic; if that set contains even one `NULL`, then testing "is x in the set" can yield `UNKNOWN` for any x not otherwise present, and negating `UNKNOWN` stays `UNKNOWN`. Since `WHERE` keeps only rows whose predicate is exactly `TRUE`, a `NOT IN` poisoned by a `NULL` silently drops rows it should keep. `EXISTS`/`NOT EXISTS`, by contrast, only ask whether the inner query returns any rows at all — a purely boolean cardinality question that is never `UNKNOWN`, regardless of `NULL` values inside those rows.
When should I use IN versus EXISTS for the same logical intent?
For small, clean, literal-like sets with no `NULL`s (e.g., `WHERE status IN ('A','B','C')`), `IN` is idiomatic and clear. For anti-join or semi-join intent driven by another table — "rows that do/don't have a match elsewhere" — prefer `EXISTS`/`NOT EXISTS`, since they avoid `NULL`-poisoning and most optimizers have well-tuned semi-join/anti-join rewrites specifically for the `EXISTS` form. In practice, many optimizers treat `IN` and `EXISTS` identically once `NULL`s are provably absent, so the choice is mostly about safety and readability rather than guaranteed performance differences.
How does a query optimizer avoid literally re-running a correlated subquery n times?
The optimizer recognizes that many correlated subqueries used as filters are logically equivalent to a semi-join (for `EXISTS`/`IN`) or an anti-join (for `NOT EXISTS`/`NOT IN`), a rewrite technique often called subquery decorrelation or unnesting. Instead of a nested loop invoking the inner query per outer row, the engine builds a hash table over the correlation column once and probes it, or performs a sort-merge join, achieving roughly linear or near-linear cost instead of the naive quadratic cost. This is exactly the kind of transformation studied under Query Optimization Fundamentals — the SQL text specifies what to compute, not how.
What's the difference between a derived table and a correlated subquery in WHERE?
A derived table is a subquery placed in the `FROM` clause, producing an entire intermediate table that the outer query can join against, filter, or aggregate, just like Joins in SQL over a base table. A `WHERE`-clause subquery instead produces a value, a set, or a boolean used purely as a filter condition, never contributing new columns to the result. Many "for all"/anti-join queries written with correlated `WHERE` subqueries can be rewritten using a derived table plus a `GROUP BY`/`HAVING COUNT(*)` comparison, and recognizing that equivalence is a common query-rewriting exercise.
Can a subquery appear in the SELECT list, and what are the rules?
Yes — a scalar subquery may appear anywhere a single value is expected, including the `SELECT` list, producing one computed column per outer row, for example `SELECT name, (SELECT COUNT(*) FROM Enrolled E WHERE E.ID = S.ID) AS num_courses FROM Student S`. It follows the same scalar-subquery rule: it must return at most one row (and one column) per evaluation, or a runtime error occurs; if it returns zero rows, the result is `NULL`. This pattern is a correlated subquery in disguise and, like `WHERE`-clause correlated subqueries, is a candidate for optimizer rewriting into a `LEFT JOIN` plus `GROUP BY`.
Does using a subquery instead of a JOIN ever change the actual result set, not just performance?
Yes, in specific cases. Replacing an `EXISTS`/semi-join subquery with a naive `INNER JOIN` can duplicate outer rows when the inner table has multiple matches (a `JOIN` returns one row per match, while `EXISTS` only tests non-emptiness), so you'd need a `DISTINCT` or an aggregation to restore the original cardinality. Likewise, `NOT IN` versus `NOT EXISTS` versus a `LEFT JOIN ... WHERE right.key IS NULL` can differ in the presence of `NULL`s, as discussed above. Always compare cardinality and `NULL` behavior explicitly before assuming two rewrites are equivalent.