skip to content

Subqueries and CTEs

Scalar, row and table subqueries, correlated versus uncorrelated evaluation, EXISTS/IN/ANY/ALL, WITH clauses including recursive CTEs, and CTE versus derived table versus view. Interviewers ask because naming the steps of a hard query — and knowing that a correlated subquery may run per row — is what separates readable SQL from slow SQL.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

What value does the correlated subquery (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) produce for each customers row?

level: juniorimportance: must knowfreq 78%

answer

  1. the inner query mentions the outer table
  2. one value per outer row, not per match
  3. the outer row's id is substituted each time
  4. SELECT-list subqueries add columns, never filter
  5. COUNT of an empty set is 0

basics

~20 s

One value per outer row: the inner SELECT is evaluated with that customer's id substituted, so each row shows that customer's own order count — and 0, not NULL, for a customer with no orders.

solid answer

~40 s

The subquery references `c.id`, a column of the enclosing query, so it cannot be evaluated on its own — conceptually it is evaluated once per row of `customers`, with that row's `id` plugged in, and the single value it yields becomes that row's `order_count`. The result therefore has exactly as many rows as `customers`: putting the subquery in the SELECT list adds a column, it never filters rows. A customer with no orders gets **0**, because `COUNT` over an empty set is 0 and an aggregate with no `GROUP BY` always returns exactly one row. Swap `COUNT(*)` for `MAX(o.amount)` and the same customer gets **NULL** instead, since `MAX` of nothing is NULL. If you removed `WHERE o.customer_id = c.id`, the subquery would be uncorrelated and would repeat one constant total on every row.

code

sql · 7 lines
sql
-- customers: (1,'Ada'), (2,'Ben'), (3,'Cleo')
-- orders:    (10,1), (11,1), (12,2)
SELECT c.id,
       c.name,
       (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;
-- 1 Ada 2 | 2 Ben 1 | 3 Cleo 0

go deeper

for a junior

Be ready to read such a query aloud row by row and state the row count of the result and the value for a customer with no orders. Knowing that COUNT gives 0 while MAX gives NULL is the whole question.

for a middle

Explain the substitution model precisely: the outer row binds the alias, the inner query yields one value, and an aggregate without GROUP BY always returns exactly one row. Contrast that with an empty result set evaluating to NULL.

for a senior

Show that you know the SELECT-list form cannot filter, and that naively converting it to an inner join silently loses zero-count rows. Expect to say what you would check in the output to catch that regression.

for a principal

Frame it as a readability and correctness convention: which shape your codebase standardises on for per-row aggregates, and why letting each author pick between correlated subqueries, joins and window aggregates produces reviews that argue about style instead of semantics.

## The two query blocks A subquery is a `SELECT` nested inside another statement. The enclosing block is the *outer* query and the nested one is the *inner* query. Each `FROM` item introduces a **range variable** — the alias `c` in `FROM customers c`, `o` in `FROM orders o` — and a qualified name like `c.id` means "the `id` column of the row currently bound to `c`". In `SELECT c.name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) FROM customers c` the inner block mentions `c.id`, a range variable owned by the outer block. That reference is what makes the subquery **correlated**: it has no meaning by itself, because `c` is only bound while the outer query is producing a row. An **uncorrelated** subquery mentions nothing from outside — `(SELECT COUNT(*) FROM orders)` is a self-contained query whose value is the same for every outer row. ## The row-by-row reading The semantics the standard gives you are: for each row the outer query produces, bind `c` to that row, evaluate the inner query with `c.id` replaced by that row's value, and use the single value it returns. ```sql -- customers: (1,'Ada'), (2,'Ben'), (3,'Cleo') -- orders: (10,1), (11,1), (12,2) -- Ada -> COUNT(*) of orders where customer_id = 1 -> 2 -- Ben -> COUNT(*) of orders where customer_id = 2 -> 1 -- Cleo -> COUNT(*) of orders where customer_id = 3 -> 0 ``` This is a *semantic* model, not a promise about execution — how an engine actually evaluates such a query is a query-planning matter, and it is free to compute the answer some entirely different way as long as the result matches. ## Why 0 and not NULL Two separate rules meet here, and mixing them up is the most common error. 1. A **scalar subquery that returns no rows at all** evaluates to NULL. 2. An **aggregate query without `GROUP BY` always returns exactly one row**, even when its input is empty. Because `SELECT COUNT(*) FROM orders o WHERE o.customer_id = 3` matches no `orders` rows but still has no `GROUP BY`, it returns one row containing `0`. So Cleo's column shows 0. Rule 2 also means `MAX`, `MIN`, `SUM` and `AVG` return one row containing **NULL** for an empty input — only `COUNT` returns a number. Add a `GROUP BY o.customer_id` inside and you change the answer for Cleo to NULL, because now there are no groups and therefore no rows at all, and rule 1 takes over. ## It adds a column; it does not filter Because the subquery sits in the SELECT list, the output has one row per `customers` row — three rows above, including Cleo with 0. Candidates routinely claim customers without orders disappear; that is what an `INNER JOIN` to `orders` would do, and it is exactly the trap when someone "optimises" this query into a join without switching to `LEFT JOIN`. To filter, the correlated predicate has to go into `WHERE`, for example `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)`. ## The scalar contract A subquery used where a single value is expected must yield at most one row and one column. `(SELECT COUNT(*) …)` is safe by construction — an aggregate without `GROUP BY` can only produce one row. `(SELECT o.amount FROM orders o WHERE o.customer_id = c.id)` is not: it returns a value for customers with exactly one order, NULL for customers with none, and raises a runtime "more than one row returned by a subquery" error the moment some customer has two. ## Spotting correlation while reading Cover the outer query with your hand and ask whether the inner block would still run. If every column it names is resolvable from its own `FROM` list, it is uncorrelated and produces one constant. If it reaches out for `c.id`, it is correlated and produces a per-row answer. That one-line test is what an interviewer is checking when they hand you an unfamiliar query. ## Common mistakes - Reporting NULL for customers with no orders (confusing an empty aggregate with an empty result set). - Believing the query returns one row per matching order pair, as a join would. - Assuming the inner query runs once for the whole statement. - Forgetting that the same shape with `MAX` or `SUM` really does produce NULL, so downstream arithmetic needs `COALESCE`.

  • How does the result change if the subquery uses MAX(o.amount) instead of COUNT(*)?
    Customers with no orders get NULL rather than 0. `MAX`, `MIN`, `SUM` and `AVG` over an empty input return one row containing NULL; `COUNT` is the one aggregate that returns a number. Wrap it in `COALESCE(..., 0)` if downstream arithmetic must not see NULL.
  • What would the query return if you deleted the subquery's WHERE clause?
    It becomes uncorrelated: `(SELECT COUNT(*) FROM orders)` is a self-contained query with one constant value, so every customer row shows the same total order count for the whole table. Nothing ties the number to the customer any more.
  • Does a correlated subquery in the SELECT list ever change how many rows come back?
    No. The SELECT list only shapes columns, so the output has exactly one row per row surviving the outer `FROM`/`WHERE`. To drop customers with no orders you need a predicate — `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)` — or an inner join.

It behaves like a spreadsheet formula filled down a column: one formula, re-evaluated against each row's own cells, producing one answer per row.

saying these in an interview costs you the question

  • Says a customer with no orders shows NULL for COUNT(*)
  • Thinks the subquery drops customers that have no orders
  • Claims the inner query is evaluated once for the whole statement
  • Reads it as a join that multiplies rows per matching order
  • Cannot say which column reference makes it correlated

context

open as a page

What is the difference between a CTE, a derived table, and a view?

level: juniorimportance: must knowfreq 70%

basics

~20 s

All three name a subresult. A derived table is a subquery written inline in FROM and used once there; a CTE is named in a WITH clause and visible only to that one statement; a view is a stored schema object any statement can query.

open as a page

What does an EXISTS subquery predicate evaluate to, and can it ever be UNKNOWN?

level: juniorimportance: must knowfreq 70%

basics

~20 s

EXISTS is TRUE when its subquery returns at least one row and FALSE when it returns none. It is two-valued and never UNKNOWN. The subquery's select list is irrelevant; only whether rows come back matters.

open as a page

What must a scalar subquery return, and what happens if it returns more than one row?

level: juniorimportance: must knowfreq 70%

basics

~20 s

A scalar subquery must produce exactly one column and at most one row, so it can stand where a single value is expected. Returning two or more rows is a cardinality error raised while the statement runs, not a syntax error caught beforehand.

open as a page

Where can a subquery appear in a SELECT statement, and what does each position contribute?

level: juniorimportance: must knowfreq 70%

basics

~20 s

A subquery can sit in the SELECT list as one computed value per row, in FROM as a derived table that must be aliased, and in WHERE or HAVING as an operand of a predicate.

open as a page

What does a WITH clause define in SQL, and how long does the named result live?

level: juniorimportance: must knowfreq 72%

basics

~20 s

WITH defines one or more named subqueries, called common table expressions, that the statement following it can reference by name as if they were tables. The names exist only for that single statement and are never stored.

open as a page

How do you rewrite a correlated scalar subquery in the SELECT list as a join or a window function?

level: middleimportance: must knowfreq 62%

basics

~20 s

Aggregating another table becomes a LEFT JOIN plus GROUP BY, counting a joined column so unmatched rows still score 0. Aggregating the same table becomes a window aggregate with PARTITION BY on the correlation key.

open as a page

Why can `id NOT IN (SELECT manager_id FROM employees)` return no rows at all?

level: middleimportance: must knowfreq 78%

basics

~20 s

If the subquery yields even one NULL, every NOT IN comparison ends in UNKNOWN rather than TRUE, and WHERE keeps only TRUE rows. The whole filter therefore matches nothing. NOT EXISTS avoids this because it is two-valued.

open as a page

What does a scalar subquery evaluate to when it selects no rows, and why is that risky?

level: middleimportance: must knowfreq 50%

basics

~20 s

A scalar subquery that matches no rows evaluates to NULL — no error, no default. That NULL then propagates through arithmetic and turns comparisons into UNKNOWN, so results quietly become NULL or rows quietly disappear from the output.

open as a page

How do you add a depth column to a recursive CTE that walks a manager_id chain?

level: middleimportance: must knowfreq 70%

basics

~20 s

Initialise the depth in the anchor member (0 or 1), then select depth + 1 in the recursive member. Each new row inherits its parent's depth plus one, so the column measures the distance in links from the starting node.

open as a page

In a WITH RECURSIVE query, what does each iteration read, and when does iteration stop?

level: middleimportance: must knowfreq 65%

basics

~20 s

Each pass of the recursive member sees only the rows the previous pass produced, not the whole accumulated result. Its output is appended to the result and becomes the input for the next pass. Iteration stops when a pass produces no rows.

open as a page

How do you stop a recursive CTE from looping forever when the hierarchy contains a cycle?

level: seniorimportance: must knowfreq 60%

basics

~20 s

A recursive CTE has no built-in loop protection. Carry the visited node ids in a path column and add a predicate in the recursive member that skips any node already there. Where implemented, the standard CYCLE clause does this for you.

open as a page

A recursive CTE never finishes and has to be killed — how do you make it terminate?

level: seniorimportance: must knowfreq 50%

basics

~20 s

Recursion ends only when a pass produces no rows, so an unbounded query means the recursive member always finds something. The reliable fix is a depth counter incremented each pass and filtered inside the recursive member, which bounds work regardless of the data.

open as a page

Why must a derived table in the FROM clause be given an alias?

level: juniorimportance: should knowfreq 55%

basics

~20 s

A derived table is a table reference in the FROM clause, and every table there needs a name so its columns can be qualified unambiguously. The standard requires that correlation name, and PostgreSQL and MySQL reject the query without it.

open as a page

In a recursive CTE over (id, parent_id), what changes when you walk ancestors instead of descendants?

level: juniorimportance: should knowfreq 50%

basics

~20 s

The join direction flips. For descendants you join the table's parent_id to the CTE's id; for ancestors you join the table's id to the CTE's parent_id. The anchor — the node you start from — is written the same way in both.

open as a page

What are the two members of a WITH RECURSIVE query, and how do they combine?

level: juniorimportance: should knowfreq 45%

basics

~20 s

A WITH RECURSIVE query has an anchor member that runs once to seed the result and does not mention the CTE's name, plus a recursive member that does reference that name and runs repeatedly. UNION ALL or UNION joins them.

open as a page

Why does SELECT * FROM customers c WHERE c.id IN (SELECT id FROM orders_archive) return every customer when orders_archive has no id column?

level: middleimportance: should knowfreq 42%

basics

~20 s

The unqualified id resolves outward to customers.id, so the subquery returns the current customer's own id for every archive row and the IN predicate is trivially true. Qualifying every inner column with its table alias turns the mistake into an error.

open as a page

Inside a subquery, which columns from enclosing queries are visible, and is the reverse allowed?

level: middleimportance: should knowfreq 32%

basics

~20 s

A subquery can reference range variables from any enclosing query block, not just the immediate parent. Visibility is one-way: the outer query sees only the subquery's returned value, never the tables or aliases used inside it.

open as a page

Why prefer a CTE over repeating a derived table when one query needs the same subresult twice?

level: middleimportance: should knowfreq 52%

basics

~20 s

A derived table's alias reaches only the block it sits in, so a second use means pasting the subquery again and keeping two copies in sync. A CTE is written once, named, and referenced as often as the statement needs.

open as a page

How do IN and NOT IN relate to the quantified predicates = ANY and <> ALL?

level: middleimportance: should knowfreq 44%

basics

~20 s

IN is defined as = ANY: TRUE if the value equals at least one subquery row. NOT IN is <> ALL: TRUE only if it differs from every row. SOME is a synonym for ANY.

open as a page

How does a row-value comparison like (a, b) = (SELECT x, y FROM t) work?

level: middleimportance: should knowfreq 35%

basics

~20 s

A row constructor on the left is compared against a one-row subquery of the same width. The comparison is true only when every corresponding pair is equal, letting a composite key be matched in a single predicate instead of several ANDed comparisons.

open as a page

What distinguishes a scalar subquery from a row subquery and a table subquery?

level: middleimportance: should knowfreq 45%

basics

~20 s

The three forms differ by result shape. A scalar subquery returns one column and at most one row and acts as a value; a row subquery returns one row of several columns and is compared against a row constructor; a table subquery returns any number of rows and columns.

open as a page

Why prefer a derived table in FROM over several scalar subqueries in the SELECT list?

level: middleimportance: should knowfreq 45%

basics

~20 s

Each select-list subquery yields one column and is usable nowhere else in the statement. A derived table computes the group once, exposes several columns at once, and the outer query can filter, join and sort on them by name.

open as a page

How do you build a breadcrumb path column in a recursive CTE over a category tree?

level: middleimportance: should knowfreq 55%

basics

~20 s

Concatenate in the recursive member: the parent row's accumulated path, a delimiter, then the current row's name. Initialise the path in the anchor with an explicit CAST to a wide string type, and ORDER BY path to print the tree depth-first.

open as a page

In WITH RECURSIVE, what changes if the members are combined with UNION instead of UNION ALL?

level: middleimportance: should knowfreq 42%

basics

~20 s

UNION discards rows that duplicate rows already produced, so a repeat row is never fed into the next pass and the iteration ends once nothing new appears. UNION ALL keeps every row and needs its own stop predicate.

open as a page

Can a CTE in a WITH clause reference another CTE, and does definition order matter?

level: middleimportance: should knowfreq 58%

basics

~20 s

Yes. A CTE may reference any CTE defined earlier in the same WITH list, which is how multi-step queries are stacked. Order matters: referring to a name defined later fails unless the CTE is recursive.

open as a page

How do you use a WITH clause with a DELETE or UPDATE, and what can the CTE see?

level: middleimportance: should knowfreq 38%

basics

~20 s

Write the WITH clause in front of the DELETE or UPDATE; the CTE names are then usable in that statement's subqueries and predicates. The names vanish when the statement ends, and engine support for WITH before DML varies, so check yours.

open as a page

Why does a top-3-per-customer query built from a correlated COUNT return four rows for some customers?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Because the inner count of strictly-later rows is equal for tied rows, so every row in a tie at the cut-off passes the predicate. The idiom ranks like RANK, not ROW_NUMBER; add a unique tiebreaker to the inner comparison for exactly N.

open as a page

When is a CTE that several reports now duplicate worth promoting to a view?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Promote when more than one statement needs the same definition and that definition is stable and meaningful on its own. A CTE dies with its statement, so every other query must copy it; a view keeps one definition everyone reads.

open as a page

A nightly job's NOT IN filter suddenly returns zero rows after a data change — how do you diagnose and permanently fix it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Check whether the subquery column now contains NULLs: one NULL makes every NOT IN comparison UNKNOWN, so the filter matches nothing. Fix durably by rewriting as NOT EXISTS and, where the model allows, declaring the column NOT NULL.

open as a page

showing 1–30 of 37