Subqueries & CTEs
In one line
A subquery is a query inside another query, and a CTE (WITH ...) gives that inner query a name so you can build a complex query in readable steps, including recursive ones that walk hierarchies.
What it is
A subquery is a SELECT nested inside another statement. A scalar subquery returns one value (SELECT AVG(salary) FROM employees). A list subquery feeds IN, EXISTS, ANY or ALL. A derived table is a subquery in FROM that acts like a temporary table for that query.
A correlated subquery refers to a column of the outer query, so it is logically evaluated once per outer row: 'employees whose salary is above the average of their own department'. The planner often rewrites it into a join, but the meaning is per row.
A common table expression (CTE) is a named subquery written up front: WITH dept_avg AS (...) SELECT ... FROM dept_avg. It makes multi-step logic readable and can be referenced more than once. WITH RECURSIVE lets a CTE refer to itself, which is how SQL walks trees and graphs such as org charts or category hierarchies.
Why interviewers ask
Interview SQL problems are usually multi-step: compute something per group, then compare rows to it, then rank. CTEs show you can decompose a problem cleanly. Interviewers also probe correctness traps (NOT IN with NULL, scalar subqueries returning two rows) and whether you know when a subquery, a join or a window function is the better tool.
How it works
- 1A scalar subquery must return at most one row and one column; if it returns no rows the value is NULL, and if it returns two rows the query fails at run time.
- 2IN (subquery) is TRUE if the value equals any returned value. EXISTS (subquery) is TRUE if the subquery returns at least one row; what it selects does not matter, so SELECT 1 is the convention.
- 3A correlated subquery like WHERE salary > (SELECT AVG(salary) FROM employees i WHERE i.dept = e.dept) recomputes the inner query for each outer row's dept. Optimisers frequently turn this into a join against pre-aggregated results.
- 4A CTE is defined with WITH name AS (query) and then used like a table. Several CTEs can be chained, each one reading earlier ones. In PostgreSQL 12 and later, a non-recursive CTE used once is usually inlined into the main query (you can force or prevent this with MATERIALIZED / NOT MATERIALIZED); before version 12 every CTE was computed separately, which could block optimisations. MySQL supports CTEs from 8.0.
- 5A recursive CTE has two parts joined by UNION ALL (or UNION): an anchor query that produces the starting rows, and a recursive query that joins the CTE's previous output to the base table to produce the next level. It repeats until a step produces no new rows.
Worked example · Above-average earners and an org chart walk
- employees(id, name, dept, salary, manager_id): (1, Asha, Eng, 120000, NULL), (2, Chen, Eng, 95000, 1), (3, Farah, Eng, 110000, 1), (4, Ben, Sales, 70000, 1), (5, Eli, Sales, 82000, 4).
- CTE dept_avg = SELECT dept, AVG(salary) GROUP BY dept: Eng = (120000 + 95000 + 110000) / 3 = 108333.33; Sales = (70000 + 82000) / 2 = 76000.
- Join employees to dept_avg on dept and keep salary > avg: Asha (120000 > 108333.33) yes, Chen no, Farah (110000) yes, Ben (70000 < 76000) no, Eli (82000) yes. Result: Asha, Farah, Eli.
- Recursive CTE 'everyone under Asha'. Anchor: Asha, level 0.
- Iteration 1: join employees whose manager_id is in the previous output (id 1): Chen, Farah, Ben at level 1.
- Iteration 2: managers are now ids 2, 3, 4. Only Eli has manager 4, so Eli at level 2.
- Iteration 3: nobody reports to Eli, the step returns no rows, recursion stops. Final result: Asha 0, Chen 1, Farah 1, Ben 1, Eli 2.
Picture it
A CTE is like doing a long calculation on paper by first writing 'let A = ...' and 'let B = ...' and then the final answer in terms of A and B. A recursive CTE is like tracing a family tree: start with one person, then list their children, then their grandchildren, until a generation has no children.
Reference code
-- employees(id, name, dept, salary, manager_id)
-- 1) Employees earning above their department average
WITH dept_avg AS (
SELECT dept, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept
)
SELECT e.name, e.dept, e.salary
FROM employees e
JOIN dept_avg d ON d.dept = e.dept
WHERE e.salary > d.avg_salary;
-- 2) Everyone in Asha's reporting chain, with depth
WITH RECURSIVE chain AS (
SELECT id, name, 0 AS level
FROM employees
WHERE id = 1 -- anchor: Asha
UNION ALL
SELECT e.id, e.name, c.level + 1
FROM employees e
JOIN chain c ON e.manager_id = c.id -- next level down
)
SELECT name, level FROM chain ORDER BY level, name;Query 1 returns Asha, Farah and Eli on the example table. Query 2 returns Asha 0, then Ben, Chen and Farah at level 1, then Eli at level 2. The RECURSIVE keyword is required in PostgreSQL and MySQL 8.0.
Key patterns
Compare each row to its group
When: Above-average salary per department, orders larger than the customer's usual
How: Aggregate in a CTE and join back on the group key, use a correlated subquery, or use AVG(...) OVER (PARTITION BY ...).
EXISTS for existence checks
When: Customers with at least one order, or with none
How: WHERE EXISTS / NOT EXISTS (SELECT 1 ... WHERE inner.key = outer.key). It stops at the first match and is safe with NULLs.
Step-by-step pipeline
When: A question needs filter, aggregate, then rank
How: One CTE per step, each with a clear name, and a short final SELECT.
Recursive hierarchy walk
When: Org charts, category trees, bill of materials, graph reachability
How: WITH RECURSIVE: anchor row(s) UNION ALL a join from the CTE to the table; carry a level or path column and add a depth limit or cycle check for graphs that may loop.
Real-world use
dbt and analytics engineering
dbt models are typically written as a chain of CTEs (import CTEs, then logic CTEs, then a final select) because each step can be read and tested on its own; the style is used across Snowflake, BigQuery and PostgreSQL warehouses.
Category trees in e-commerce
A store with categories like Electronics > Phones > Cases stores parent_id per category and uses WITH RECURSIVE to find all subcategories, so a click on Electronics shows products from every level below.
Comment threads
Forums storing replies with a parent_comment_id can fetch a whole thread with a recursive CTE, carrying a depth or path column to indent and order the replies.
Access control hierarchies
Internal tools that let a manager see their whole reporting chain walk the org chart recursively from the manager's id, as in the example.
In a project
Finance asks: for each month, which customers spent more than twice their own average monthly spend? The first draft is a 60-line query with three nested subqueries nobody can review.
- Rewrite it as CTEs: monthly AS (spend per customer per month), customer_avg AS (average of monthly per customer), flagged AS (join the two and keep spend > 2 x avg).
- Test each CTE on its own by temporarily selecting from it, checking row counts and a few customers by hand.
- Replace a NOT IN (SELECT customer_id FROM test_accounts) filter with NOT EXISTS, because test_accounts.customer_id had NULLs and was silently removing every row.
- Run EXPLAIN ANALYZE to confirm PostgreSQL inlines the CTEs and uses the index on (customer_id, month).
Impact: The query became reviewable in minutes, a hidden NULL bug that made the report empty for a week was fixed, and runtime stayed the same because modern PostgreSQL optimises inlined CTEs like ordinary subqueries.
Trade-offs
Use it when
- CTEs for multi-step logic that should be readable and reviewable
- EXISTS / NOT EXISTS for membership tests, especially when NULLs are possible
- Recursive CTEs for hierarchies stored as parent_id columns
- A scalar subquery for a single value like the overall average
Avoid it when
- A correlated subquery in SELECT that runs a heavy query per row on a big table, when one join to pre-aggregated data would do
- NOT IN with a subquery that can return NULL
- Relying on CTEs as an optimisation fence in PostgreSQL 12+ without writing MATERIALIZED explicitly
- Recursive CTEs over data that can contain cycles without a depth limit or cycle check
Cost: A naively executed correlated subquery is O(n x m); planners often decorrelate it into a join, about O(n + m) with hashing. A recursive CTE costs roughly the sum of each level's join, proportional to the number of rows reached when the join column is indexed.
Common mistakes
- Writing NOT IN (SELECT col ...) when col can be NULL: x NOT IN (1, NULL) is never TRUE, so no rows come back.
- Using = with a subquery that can return more than one row, which fails at run time with 'more than one row returned by a subquery'.
- Forgetting the alias on a derived table: older PostgreSQL versions (before 16) and MySQL require FROM (SELECT ...) AS t.
- Writing a recursive CTE whose recursive part never stops, for example on a graph with a cycle, and using UNION ALL with no depth limit.
- Assuming a CTE is always computed once and cached. In PostgreSQL 12+ it is usually inlined, and in MySQL it may be merged or materialised depending on the optimiser.
Interview questions
Quick revision
- Scalar subquery: one row, one column; no rows gives NULL, two rows is an error.
- Correlated subquery references the outer row; logically runs per row.
- EXISTS / NOT EXISTS: safe with NULLs, stop at first match.
- NOT IN with a NULL in the list returns nothing.
- WITH name AS (...) names a step; chain several CTEs for readability.
- WITH RECURSIVE: anchor UNION ALL recursive step; stops when a step adds no rows.
- PostgreSQL 12+: single-use CTEs are inlined unless you write MATERIALIZED.
- MySQL supports CTEs and recursive CTEs from 8.0.
Check yourself
1. employees.manager_id contains a NULL for the CEO. What does SELECT name FROM employees WHERE id NOT IN (SELECT manager_id FROM employees) return?
2. When does a recursive CTE stop?
3. What makes a subquery correlated?
4. In PostgreSQL 12+, what usually happens to a non-recursive CTE that is referenced once?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.