CTEs, recursion, subqueries
Recursive CTEs made SQL Turing-complete in 1999. More practically, they turn a class of problems that previously required application code — tree traversal, graph reachability, generating sequences — into a single query. This part covers when a CTE is free, when it is a barrier, and how the recursion actually executes.
Subquery types
Four kinds, distinguished by what they return and where they may appear.
| Type | Returns | Valid in |
|---|---|---|
| Scalar | Exactly one row, one column | Anywhere a value is expected — SELECT, WHERE, even ORDER BY |
| Row | One row, several columns | Comparisons against a row value (ch 12) |
| Table | Many rows | FROM (a derived table), IN, EXISTS |
| Correlated | Any of the above | References the outer query, so it is re-evaluated per outer row |
-- scalar: must return exactly one value, or it is a runtime error
SELECT name, (SELECT MAX(total) FROM orders) AS biggest FROM users;
-- correlated scalar: one per outer row
SELECT u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS n
FROM users u;
-- table subquery in FROM: a derived table. it MUST be aliased.
SELECT * FROM (SELECT id, total FROM orders WHERE total > 100) big;LIMIT 1 with an explicit ORDER BY, or use an
aggregate to make the intent unambiguous.Correlated subqueries, and the N+1 inside SQL
A correlated subquery is conceptually a loop: for each outer row, run the inner query. Modern optimizers often rewrite it into a join or semijoin (MySQL module, ch 81), but not always — and when they cannot, the cost is exactly as bad as it sounds.
-- correlated: conceptually one execution per user
SELECT u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS n
FROM users u;
-- the join form: one pass, aggregated once
SELECT u.name, COALESCE(o.n, 0) AS n
FROM users u
LEFT JOIN (SELECT user_id, COUNT(*) AS n
FROM orders GROUP BY user_id) o
ON o.user_id = u.id;The heuristic: few outer rows and an indexed inner → correlated is fine. Many outer rows → aggregate once and join. Measure with
EXPLAIN ANALYZE
and look at the loops count (MySQL module, ch 85) — it tells you
directly whether the optimizer flattened it.CTEs as naming, and as optimization fences
A common table expression names a subquery so it can be referenced by name, reused, and read top-to-bottom instead of inside-out.
WITH recent AS (
SELECT * FROM orders WHERE created_at >= '2026-01-01'
),
per_user AS (
SELECT user_id, COUNT(*) AS n, SUM(total) AS spend
FROM recent GROUP BY user_id -- a CTE can use an earlier CTE
)
SELECT u.name, p.n, p.spend
FROM per_user p JOIN users u ON u.id = p.user_id
WHERE p.spend > 1000;The fence question, which differs by engine
| Engine | Behaviour |
|---|---|
| Postgres ≤ 11 | Always materialized. A CTE was an optimization fence — predicates could not push into it. This is the origin of "CTEs are slow in Postgres". |
| Postgres 12+ | Inlined when referenced once and side-effect free. MATERIALIZED / NOT MATERIALIZED force either behaviour. |
| MySQL 8.0+ | Merged where possible, materialized otherwise — same rules as derived tables (MySQL module, ch 82). |
-- Postgres 12+: force materialization when you WANT the fence WITH expensive AS MATERIALIZED ( SELECT ... -- computed once, reused by every reference ) SELECT ... FROM expensive a JOIN expensive b ON ...;
Conversely, if a CTE is referenced once and you want predicates pushed into it, inlining is what you want. Know which engine you are on and which behaviour you are getting;
EXPLAIN will show a materialization step when one
happens.Recursive CTEs
The mechanism is a fixpoint loop, and it is much simpler than the word "recursive" suggests. There is no call stack — it is iteration over a working table.
WITH RECURSIVE tree AS (
-- ANCHOR: the starting rows. runs once.
SELECT id, name, manager_id, 1 AS depth
FROM emp WHERE manager_id IS NULL
UNION ALL
-- RECURSIVE TERM: joins the table to the PREVIOUS iteration's rows.
-- runs repeatedly until it produces no new rows.
SELECT e.id, e.name, e.manager_id, t.depth + 1
FROM emp e
JOIN tree t ON e.manager_id = t.id -- ← references itself
)
SELECT * FROM tree ORDER BY depth, name;The execution algorithm, exactly
- Evaluate the anchor. Its rows go into the result and into a working table.
- Evaluate the recursive term, where the self-reference means "the working table" — that is, only the rows produced by the previous iteration, not everything so far.
- Append those rows to the result; they become the new working table.
- Repeat from 2 until an iteration produces zero rows.
depth + 1 works as a level
counter.
Also:
UNION ALL keeps duplicates and terminates only when no new
rows appear; UNION deduplicates, which will terminate a cyclic
graph but hides genuine duplicate paths. Chapter 44 covers doing it properly.CREATE TABLE emp (id INT, name VARCHAR(20), manager_id INT);
INSERT INTO emp VALUES
(1,'ada',NULL), (2,'bode',1), (3,'chi',1),
(4,'dami',2), (5,'eze',4);
WITH RECURSIVE tree AS (
SELECT id, name, 1 AS depth,
CAST(name AS CHAR(200)) AS path
FROM emp WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, t.depth + 1,
CONCAT(t.path, ' > ', e.name)
FROM emp e JOIN tree t ON e.manager_id = t.id
)
SELECT depth, LPAD(name, depth*2 + LENGTH(name), ' ') AS tree, path
FROM tree ORDER BY path;
-- depth tree path
-- 1 ada ada
-- 2 bode ada > bode
-- 3 dami ada > bode > dami
-- 4 eze ada > bode > dami > eze
-- 2 chi ada > chi
-- NOTE the CAST on the anchor's path column. The anchor
-- determines the column TYPE for the whole CTE, so without it
-- the path is sized to the first name and gets truncated.
-- This is the commonest recursive-CTE error.Graph traversal and cycle detection
A tree cannot cycle; a graph can. A recursive CTE over a cyclic graph with
UNION ALL runs forever, stopped only by
cte_max_recursion_depth or the server running out of memory.
Cycle detection by path
-- carry the visited path and refuse to revisit
WITH RECURSIVE reach AS (
SELECT src, dst,
CAST(CONCAT(',', src, ',', dst, ',') AS CHAR(1000)) AS path,
1 AS hops
FROM edges WHERE src = 1
UNION ALL
SELECT r.src, e.dst,
CONCAT(r.path, e.dst, ','),
r.hops + 1
FROM edges e
JOIN reach r ON e.src = r.dst
WHERE r.path NOT LIKE CONCAT('%,', e.dst, ',%') -- ← the guard
AND r.hops < 20 -- ← belt and braces
)
SELECT DISTINCT dst, MIN(hops) AS shortest
FROM reach GROUP BY dst;Postgres has a declarative form for exactly this, added in version 14:
-- Postgres 14+ WITH RECURSIVE reach AS (...) CYCLE dst SET is_cycle USING path SELECT * FROM reach WHERE NOT is_cycle;
hops < N predicate costs nothing and converts an
outage into a wrong answer you can detect.Generating series and gap-filling
Recursion is also how you generate rows that do not exist — the fix for reports with missing days.
-- every day in a range, whether or not data exists
WITH RECURSIVE days AS (
SELECT DATE('2026-01-01') AS d
UNION ALL
SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM days
WHERE d < '2026-01-31'
)
SELECT days.d, COALESCE(SUM(s.amount), 0) AS revenue
FROM days
LEFT JOIN sales s ON DATE(s.created_at) = days.d
GROUP BY days.d
ORDER BY days.d;
-- without the generated series, days with no sales are simply
-- absent from the result — and a chart silently skips them.Postgres has a built-in that is both clearer and faster:
-- Postgres SELECT d::date FROM generate_series( '2026-01-01'::date, '2026-01-31'::date, '1 day') d;
calendar table — one row per date, with columns for weekday,
quarter, holiday flags — is faster than generating a series each time and
carries useful attributes recursion cannot produce. Generate it once, index the
date, and join to it.Hierarchy models
Four ways to store a tree, with genuinely different trade-offs. The choice should follow your read/write ratio.
| Model | Stores | Read subtree | Move a node |
|---|---|---|---|
| Adjacency list | parent_id | Recursive CTE | One UPDATE |
| Path enumeration | '/1/4/9/' | LIKE '/1/4/%' — one index range scan | Rewrite every descendant's path |
| Nested sets | lft, rgt | WHERE lft BETWEEN ... — very fast | Renumber much of the tree |
| Closure table | Every ancestor–descendant pair | One indexed join | Delete and reinsert that node's pairs |
-- closure table: the most flexible model CREATE TABLE tree_paths ( ancestor INT NOT NULL, descendant INT NOT NULL, depth INT NOT NULL, PRIMARY KEY (ancestor, descendant), INDEX (descendant, ancestor) -- for upward queries ); -- every node also has a self-row at depth 0. -- entire subtree, no recursion: SELECT n.* FROM nodes n JOIN tree_paths p ON p.descendant = n.id WHERE p.ancestor = 4; -- all ancestors of a node, ordered from the root: SELECT n.* FROM nodes n JOIN tree_paths p ON p.ancestor = n.id WHERE p.descendant = 9 ORDER BY p.depth DESC;
Nested sets look elegant in articles and are painful in production: any insert renumbers a large fraction of the table, which does not survive concurrent writes.
Recursion limits
-- MySQL: hard iteration cap, default 1000 SELECT @@cte_max_recursion_depth; SET SESSION cte_max_recursion_depth = 10000; -- also bounded by execution time: SET SESSION max_execution_time = 5000; -- ms, SELECT only -- Postgres has NO recursion limit. an unbounded recursive CTE -- will consume memory until the query is killed. bound it: -- SET statement_timeout = '5s'; -- the portable guard is in the query itself: -- ... WHERE depth < 50
① Is there a termination condition that will definitely be reached?
② Is there a depth bound as a backstop?
③ Can the data contain a cycle, and is it guarded?
④ Are the anchor's column types wide enough for the deepest row — especially any concatenated path?
⑤ Is the join column in the recursive term indexed?
Point ④ is the silent one: the anchor determines the column type for the whole CTE, so a path built from short names truncates as the tree deepens, and the truncation can even break the cycle guard.
Part 6 moves from querying to defining: types, constraints, and treating the schema as a contract rather than a container.