CTEs, Subqueries, and Recursion
Why a named CTE scores better than a nested subquery that returns the same rows, when a correlated subquery runs once per row, and recursive CTEs for hierarchies.
A CTE and a subquery usually compile to the same plan. They do not read the same, and readability is a scored criterion.
The same query, two ways to write it
-- nested: correct, and nobody can verify it by reading
SELECT name, total FROM (
SELECT c.name, sum(o.amount) AS total FROM customers c
JOIN orders o USING (customer_id)
WHERE o.status = 'shipped'
GROUP BY c.name
) t
WHERE total > (
SELECT avg(total) FROM (
SELECT sum(amount) AS total FROM orders
WHERE status = 'shipped' GROUP BY customer_id
) u
)
ORDER BY total DESC;
-- CTEs: same plan, readable top to bottom
WITH shipped AS (
SELECT customer_id, amount FROM orders WHERE status = 'shipped'
),
per_customer AS (
SELECT customer_id, sum(amount) AS total FROM shipped GROUP BY customer_id
),
average AS (
SELECT avg(total) AS avg_total FROM per_customer
)
SELECT c.name, p.total, round(a.avg_total, 2) AS avg_total
FROM per_customer p
JOIN customers c USING (customer_id)
CROSS JOIN average a
WHERE p.total > a.avg_total
ORDER BY p.total DESC;
┌─────────┬────────┬───────────┐
│ name │ total │ avg_total │
├─────────┼────────┼───────────┤
│ Ana │ 400.50 │ 233.88 │
│ Bo │ 310.00 │ 233.88 │
└─────────┴────────┴───────────┘
The average is 233.88 rather than 286.83 because per_customer groups by customer_id before
the join to customers — so order 108’s NULL customer contributes a fourth total of 75.00 to
the average, then disappears from the output. Reading the number back and asking “average of
how many customers?” is what surfaces it. Whether that row belongs in scope is a question for
the interviewer.
Each CTE can be run alone. That is the practical argument, and the one to use in an
interview: SELECT * FROM per_customer is a valid query, so you can verify each step before
composing them. A nested subquery has to be unpicked before it can be tested.
CTEs are not a performance fence any more
EXPLAIN
WITH big AS (SELECT * FROM orders)
SELECT count(*) FROM big WHERE status = 'shipped';
┌───────────────────────────┐
│ Physical Plan │
├───────────────────────────┤
│ UNGROUPED_AGGREGATE │
│ count_star() │
│ │ │
│ PROJECTION │
│ │ │
│ SEQ_SCAN │
│ orders │
│ Filters: status='shipped'│
└───────────────────────────┘
The filter was pushed into the scan — the CTE did not force orders to be materialised first.
PostgreSQL behaved differently before version 12: every CTE was an optimisation fence, materialised whole. That is where “CTEs are slow” comes from, and it is now nine years out of date. From 12 onward:
WITH big AS MATERIALIZED (SELECT * FROM orders) ... -- force the old behaviour
WITH big AS NOT MATERIALIZED (SELECT * FROM orders) ... -- force inlining
Materialising deliberately is occasionally right — when a CTE is expensive and referenced three times, computing it once beats inlining it three times.
Correlated subqueries run per row
-- correlated: the inner query references the outer row
SELECT c.name,
(SELECT count(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count,
(SELECT max(o.amount) FROM orders o WHERE o.customer_id = c.customer_id) AS biggest
FROM customers c ORDER BY c.name;
┌─────────┬─────────────┬─────────┐
│ name │ order_count │ biggest │
├─────────┼─────────────┼─────────┤
│ Ana │ 3 │ 200.00 │
│ Bo │ 2 │ 310.00 │
│ Cy │ 1 │ 45.00 │
│ Di │ 1 │ 150.00 │
│ Eve │ 0 │ NULL │
│ Fay │ 0 │ NULL │
└─────────┴─────────────┴─────────┘
That result is correct and includes the zero-order customers for free — a genuine advantage over
a join, which needs LEFT JOIN plus count(col).
The cost is two subqueries per customer. With six customers that is twelve inner executions; with a million it is two million. The join version is one pass:
SELECT c.name, count(o.order_id) AS order_count, max(o.amount) AS biggest
FROM customers c LEFT JOIN orders o USING (customer_id)
GROUP BY c.name ORDER BY c.name;
┌─────────┬─────────────┬─────────┐
│ name │ order_count │ biggest │
├─────────┼─────────────┼─────────┤
│ Ana │ 3 │ 200.00 │
│ Bo │ 2 │ 310.00 │
│ Cy │ 1 │ 45.00 │
│ Di │ 1 │ 150.00 │
│ Eve │ 0 │ NULL │
│ Fay │ 0 │ NULL │
└─────────┴─────────────┴─────────┘
“Two scalar subqueries in the SELECT list means two inner executions per outer row unless the planner rewrites them. On six rows it does not matter; on a million-row customers table I’d use the LEFT JOIN, which is one pass.
EXISTSandINusually get rewritten into semi-joins; a scalar subquery in SELECT often does not.”
Saying that unprompted is what the “awareness” criterion from the intro is scored on.
EXISTS versus IN versus JOIN
-- three ways to ask "customers who have ordered"
SELECT name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)
ORDER BY name;
SELECT name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders)
ORDER BY name;
SELECT DISTINCT c.name FROM customers c
JOIN orders o USING (customer_id) ORDER BY c.name;
┌─────────┐ ┌─────────┐ ┌─────────┐
│ name │ │ name │ │ name │
├─────────┤ ├─────────┤ ├─────────┤
│ Ana │ │ Ana │ │ Ana │
│ Bo │ │ Bo │ │ Bo │
│ Cy │ │ Cy │ │ Cy │
│ Di │ │ Di │ │ Di │
└─────────┘ └─────────┘ └─────────┘
Identical here. The differences that matter:
EXISTSstops at the first match — it is a semi-join, and it is NULL-safe. Prefer it.INwith a NULL is safe for the positive case;NOT INwith a NULL is not, as the joins lesson showed.JOINneedsDISTINCTbecause a customer with three orders would appear three times. TheDISTINCTis the tell that a join is being used for an existence check — usually a sign thatEXISTSwas the right tool.
Recursive CTEs: walking a hierarchy
CREATE TABLE employees (id INTEGER, name VARCHAR, manager_id INTEGER);
INSERT INTO employees VALUES
(1, 'Root', NULL),
(2, 'Ana', 1), (3, 'Bo', 1),
(4, 'Cy', 2), (5, 'Di', 2), (6, 'Eve', 3),
(7, 'Fay', 4);
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS depth, name AS path
FROM employees WHERE manager_id IS NULL -- anchor
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1, c.path || ' > ' || e.name
FROM employees e JOIN chain c ON e.manager_id = c.id -- recursive step
)
SELECT depth, name, path FROM chain ORDER BY path;
┌───────┬─────────┬──────────────────────┐
│ depth │ name │ path │
├───────┼─────────┼──────────────────────┤
│ 1 │ Root │ Root │
│ 2 │ Ana │ Root > Ana │
│ 3 │ Cy │ Root > Ana > Cy │
│ 4 │ Fay │ Root > Ana > Cy > Fay│
│ 3 │ Di │ Root > Ana > Di │
│ 2 │ Bo │ Root > Bo │
│ 3 │ Eve │ Root > Bo > Eve │
└───────┴─────────┴──────────────────────┘
The structure is always the same three parts:
WITH RECURSIVE name AS (
<anchor: the starting rows, no self-reference>
UNION ALL
<recursive step: joins the table to `name` itself>
)
The engine runs the anchor, then runs the recursive step against only the rows produced by the previous iteration, until an iteration produces nothing.
Infinite recursion, and the guard
INSERT INTO employees VALUES (8, 'Loop', 9), (9, 'Back', 8); -- a cycle
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS depth FROM employees WHERE id = 8
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1
FROM employees e JOIN chain c ON e.manager_id = c.id
)
SELECT count(*) FROM chain;
Error: Binder Error: Recursive CTE exceeded maximum recursion depth
Two employees pointing at each other. The recursive step never returns empty, and only the engine’s depth limit stops it.
The guard is a depth counter you enforce yourself:
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS depth FROM employees WHERE id = 8
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1
FROM employees e JOIN chain c ON e.manager_id = c.id
WHERE c.depth < 10 -- the guard
)
SELECT id, name, depth FROM chain ORDER BY depth;
┌───────┬─────────┬───────┐
│ id │ name │ depth │
├───────┼─────────┼───────┤
│ 8 │ Loop │ 1 │
│ 9 │ Back │ 2 │
│ 8 │ Loop │ 3 │
│ 9 │ Back │ 4 │
│ 8 │ Loop │ 5 │
│ 9 │ Back │ 6 │
│ 8 │ Loop │ 7 │
│ 9 │ Back │ 8 │
│ 8 │ Loop │ 9 │
│ 9 │ Back │ 10 │
└───────┴─────────┴───────┘
It terminates, and the repetition makes the cycle visible — which is often the actual goal.
UNION instead of UNION ALL deduplicates and terminates on its own; carrying a visited path
and checking NOT contains(path, e.name) is the version that reports where the cycle is.
“Any recursive CTE on data that could contain a cycle needs a depth guard. I add one by default and treat hitting it as a data quality alert, not a query bug.”
Generating a series
WITH RECURSIVE dates AS (
SELECT DATE '2024-03-01' AS d
UNION ALL
SELECT d + INTERVAL 1 DAY FROM dates WHERE d < DATE '2024-03-05'
)
SELECT d::DATE AS day,
coalesce(count(o.order_id), 0) AS orders
FROM dates LEFT JOIN orders o ON o.order_date = dates.d
GROUP BY d ORDER BY d;
┌────────────┬────────┐
│ day │ orders │
├────────────┼────────┤
│ 2024-03-01 │ 1 │
│ 2024-03-02 │ 0 │
│ 2024-03-03 │ 0 │
│ 2024-03-04 │ 0 │
│ 2024-03-05 │ 0 │
└────────────┴────────┘
Generating a complete date spine and LEFT JOINing to it is how you get zero rows for days with
no activity — the thing a plain GROUP BY order_date can never produce, because absent days
have no rows to group.
PostgreSQL has generate_series(start, stop, interval) and DuckDB has both; the recursive form
works everywhere and is worth knowing for that reason.
Recognising it
SIGNAL TOOL
query has more than one logical step CTE per step, named
same subquery used twice one CTE, referenced twice
"does a related row exist" EXISTS — semi-join, NULL-safe
"does no related row exist" NOT EXISTS — never NOT IN
"a value from a related row, per row" correlated scalar subquery, or a join
org chart, category tree, bill of materials recursive CTE
"every day / month, including empty ones" generate a spine, LEFT JOIN to it
graph traversal, shortest path in SQL recursive CTE with a depth guard
CTE referenced 3+ times and expensive MATERIALIZED (PostgreSQL 12+)
Practice
1. Rewrite a three-level nested subquery as CTEs.
Each CTE runs alone: SELECT * FROM per_customer;
Same plan on any modern engine, and every step is independently testable. Readability is a scored criterion, not a style preference.
2. EXPLAIN a query wrapped in a CTE.
Filters: status='shipped' — pushed into the scan
The CTE was inlined. PostgreSQL before 12 materialised every CTE, which is where the “CTEs are slow” reputation comes from.
3. Put two scalar subqueries in the SELECT list.
Two inner executions per outer row — 2 million on a million-row table.
Correct, and it gives zero-order customers for free. The LEFT JOIN version is one pass. Name the tradeoff rather than picking silently.
4. Run a recursive CTE over data containing a cycle.
Binder Error: Recursive CTE exceeded maximum recursion depth
Add WHERE depth < N to the recursive step. Hitting the guard is a data quality signal, not a
query bug.
Next: dates, cohorts and retention — the questions that look like SQL and are really calendar arithmetic.