Back to the map

Module 17 · SQL and relational algebra · Lesson 3 of 5

Subqueries, CTEs and set operations

Queries inside queries: scalar, row and table subqueries, correlated versus uncorrelated, IN, EXISTS, ANY and ALL, common table expressions and recursive CTEs for hierarchies, and the set operations UNION, INTERSECT and EXCEPT.

CoreMust-knowMid 12 min Updated Nov 2026

Why it matters

Many questions are naturally nested: "employees earning more than their department's average", "products never ordered", "the whole reporting chain under a manager". Subqueries and common table expressions express that nesting directly, set operations combine whole result sets, and recursive CTEs handle hierarchies that plain SQL cannot. They are standard interview material and the main tool for making complex SQL readable.

The idea

A subquery is a SELECT used inside another statement. Where it appears decides what shape it must return.

KindReturnsUsed inExample
ScalarOne valueSELECT list, WHERE comparisonsWHERE salary > (SELECT AVG(salary) FROM employee)
RowOne row of several columnsRow comparisonsWHERE (dept_id, grade) = (SELECT ...)
Table (derived table)A relationFROMFROM (SELECT dept_id, AVG(salary) AS avg_sal ...) t
Predicate subqueryRows tested for existence or membershipIN, EXISTS, ANY, ALLWHERE EXISTS (SELECT 1 FROM orders o WHERE ...)

Correlated versus uncorrelated

An uncorrelated subquery does not refer to the outer query; it can be computed once. A correlated subquery refers to columns of the outer row and logically runs once per outer row:

-- employees earning more than the average of their own department (correlated)
SELECT e.name, e.salary
FROM employee e
WHERE e.salary > (SELECT AVG(e2.salary) FROM employee e2 WHERE e2.dept_id = e.dept_id);

Modern optimizers usually decorrelate such queries into joins with a grouped derived table, so correlated does not automatically mean slow; check the plan. The same question can also be written with a window function, covered next lesson.

IN, EXISTS, ANY and ALL

PredicateTrue whenWatch out for
x IN (subquery)x equals some returned valueFine; but NOT IN fails if the subquery returns NULL
EXISTS (subquery)The subquery returns at least one rowSelected columns are irrelevant; SELECT 1 is conventional
NOT EXISTS (subquery)The subquery returns no rowsThe safe anti join
x > ANY (subquery)x is greater than at least one valueEquivalent to x > MIN(...)
x > ALL (subquery)x is greater than every valueTrue for an empty subquery; NULLs make it UNKNOWN

Common table expressions

A CTE names a subquery with WITH so the main query reads top to bottom, and the same intermediate result can be referenced more than once:

WITH dept_avg AS (
  SELECT dept_id, AVG(salary) AS avg_salary
  FROM employee
  GROUP BY dept_id
)
SELECT e.name, e.salary, d.avg_salary
FROM employee e
JOIN dept_avg d ON d.dept_id = e.dept_id
WHERE e.salary > d.avg_salary;

Most databases inline CTEs like views and optimise across them; some can materialise a CTE once (PostgreSQL lets you choose with MATERIALIZED). CTEs are primarily a readability tool.

Recursive CTEs

A recursive CTE has an anchor part and a recursive part joined with UNION ALL; it repeats the recursive part on the rows produced by the previous step until no new rows appear. It is how SQL walks hierarchies and graphs.

Anchormanager 7Level 1reports of 7Level 2their reportsuntil emptyrecursive part: JOIN employee ON manager_id = previous level's emp_id
A recursive CTE walking a reporting chain: the anchor selects the starting manager, each iteration adds the next level of reports, and it stops when a level is empty.
WITH RECURSIVE chain AS (
  SELECT emp_id, name, manager_id, 0 AS depth FROM employee WHERE emp_id = 7
  UNION ALL
  SELECT e.emp_id, e.name, e.manager_id, c.depth + 1
  FROM employee e JOIN chain c ON e.manager_id = c.emp_id
)
SELECT * FROM chain;

Guard against cycles in graph data with a depth limit or a visited-path column.

Set operations

UNION, INTERSECT and EXCEPT combine two result sets with the same number of compatible columns. UNION, INTERSECT and EXCEPT remove duplicates; UNION ALL keeps them and is faster because it skips the de-duplication sort or hash, so use it whenever duplicates are impossible or wanted. Column names come from the first query, and ORDER BY applies to the combined result.

Trade-offs

Subqueries often read closest to the English question; joins and window functions often run faster or make the plan clearer. CTEs improve readability at no cost in most engines, but materialised CTEs can block optimisations such as pushing filters inside. Recursive CTEs make hierarchies possible in plain SQL but can be expensive on deep or cyclic graphs, where a dedicated structure (closure tables, a graph database) may be better.

In an interview

Pick the clearest form and mention alternatives: "Employees above their department average: a correlated subquery reads naturally; a CTE with the department averages joined back is clearer and usually the same plan; AVG as a window function avoids the join altogether." For hierarchies, write the recursive CTE with an anchor and a recursive step and mention cycle protection.

Check yourself

A query must return all product ids that appear in either last year's or this year's catalogue, and the two lists never overlap. Which operator is the best choice?

Subqueries, CTEs and set operations · Backplane