Part 5 · 8 chapters · ~20 min

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.

40

Subquery types

Four kinds, distinguished by what they return and where they may appear.

TypeReturnsValid in
ScalarExactly one row, one columnAnywhere a value is expected — SELECT, WHERE, even ORDER BY
RowOne row, several columnsComparisons against a row value (ch 12)
TableMany rowsFROM (a derived table), IN, EXISTS
CorrelatedAny of the aboveReferences the outer query, so it is re-evaluated per outer row
code
-- 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;
the scalar subquery failure mode
A scalar subquery returning more than one row raises a runtime error ("Subquery returns more than 1 row"), not a compile-time one. So a query that works on today's data can fail tomorrow when a second matching row appears — typically at the worst moment. If more than one row is possible, add LIMIT 1 with an explicit ORDER BY, or use an aggregate to make the intent unambiguous.
41

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.

code
-- 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;
when the correlated form is actually better
When the outer query returns few rows and the inner has a supporting index, the correlated form does a handful of index lookups while the join form aggregates the entire orders table before discarding almost all of it.

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.
42

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.

code
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

EngineBehaviour
Postgres ≤ 11Always 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).
code
-- 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 ...;
the genuine optimization case
A CTE referenced several times is materialized once and reused. A repeated subquery is evaluated once per occurrence. So when the same expensive intermediate result is needed twice, the CTE is not merely tidier — it halves the work.

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.
43

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.

code
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

  1. Evaluate the anchor. Its rows go into the result and into a working table.
  2. 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.
  3. Append those rows to the result; they become the new working table.
  4. Repeat from 2 until an iteration produces zero rows.
the point people get wrong
The self-reference sees only the previous iteration's rows, not the accumulated result. That is what makes it a breadth-first traversal producing one level per iteration — and why 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.
run it
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.
recursive CTE
a fixpoint loop, iteration by iteration
swipe the figure sideways, or tap expand for full screen
1/6
anchor
The anchor runs once. It produces the starting rows — here, the one employee with no manager.
44

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

code
-- 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:

code
-- Postgres 14+
WITH RECURSIVE reach AS (...)
  CYCLE dst SET is_cycle USING path
SELECT * FROM reach WHERE NOT is_cycle;
always bound the recursion
Include a depth limit even when you believe the graph is acyclic. Data acquires cycles — a bad import, a UI that lets someone set a category's parent to its own child — and the failure mode without a bound is a query that consumes the server. A hops < N predicate costs nothing and converts an outage into a wrong answer you can detect.
45

Generating series and gap-filling

Recursion is also how you generate rows that do not exist — the fix for reports with missing days.

code
-- 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:

code
-- Postgres
SELECT d::date FROM generate_series(
  '2026-01-01'::date, '2026-01-31'::date, '1 day') d;
the alternative worth knowing
For a production system that does this often, a permanent 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.
46

Hierarchy models

Four ways to store a tree, with genuinely different trade-offs. The choice should follow your read/write ratio.

ModelStoresRead subtreeMove a node
Adjacency listparent_idRecursive CTEOne UPDATE
Path enumeration'/1/4/9/'LIKE '/1/4/%' — one index range scanRewrite every descendant's path
Nested setslft, rgtWHERE lft BETWEEN ... — very fastRenumber much of the tree
Closure tableEvery ancestor–descendant pairOne indexed joinDelete and reinsert that node's pairs
code
-- 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;
the recommendation
Adjacency list plus recursive CTEs for most applications: simplest to maintain, cheap writes, and recursion is fast enough for the depths real hierarchies reach. Add a closure table alongside it when subtree reads are hot and the tree changes rarely — org charts, category trees, permission hierarchies.

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.
47

Recursion limits

run it
-- 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
the recursion checklist
Before shipping any recursive CTE:
① 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.