CTEs and recursive queries
Article.
A common table expression names an intermediate result so a query reads as a
sequence of steps instead of a nest of subqueries. That much is presentation.
The parts worth knowing are that a CTE used to be an optimisation fence and
sometimes still is, and that WITH RECURSIVE is a genuine loop — the only one
in standard SQL.
The readability case
Here is the same question written twice: which customers spent more than twice the average last quarter?
-- Nested. You read it inside-out, and the middle is load-bearing.
SELECT c.name, t.total
FROM customer c
JOIN (
SELECT customer_id, sum(amount) AS total
FROM invoice
WHERE paid_at >= date_trunc('quarter', now()) - interval '3 months'
AND paid_at < date_trunc('quarter', now())
GROUP BY customer_id
) t ON t.customer_id = c.id
WHERE t.total > (
SELECT avg(total) * 2 FROM (
SELECT sum(amount) AS total FROM invoice
WHERE paid_at >= date_trunc('quarter', now()) - interval '3 months'
AND paid_at < date_trunc('quarter', now())
GROUP BY customer_id
) x
);The same query, named
The subquery is written twice, so it can drift. The CTE version names it once:
WITH last_quarter AS (
SELECT customer_id, sum(amount) AS total
FROM invoice
WHERE paid_at >= date_trunc('quarter', now()) - interval '3 months'
AND paid_at < date_trunc('quarter', now())
GROUP BY customer_id
),
threshold AS (
SELECT avg(total) * 2 AS cutoff FROM last_quarter
)
SELECT c.name, q.total
FROM last_quarter q
JOIN customer c ON c.id = q.customer_id
CROSS JOIN threshold t
WHERE q.total > t.cutoff;Top to bottom, each step named, the definition in one place. That is most of why people use them.
The optimisation fence
This is the part that separates people who have used CTEs from people who have debugged them.
Before Postgres 12, a CTE was always materialised. The planner computed it
fully, into a temporary result, and could not push conditions from the outer
query into it. WITH was effectively a barrier.
That behaviour bites like this:
WITH all_invoices AS (
SELECT * FROM invoice -- 40 million rows
)
SELECT * FROM all_invoices WHERE id = 12345;On Postgres 11, that materialises forty million rows and then finds one. The same query without the CTE is an index lookup. The CTE turned a 0.1ms query into a sequential scan of the whole table — and nothing in the SQL says so.
What changed in 12
Postgres 12 inlines a CTE by default, so the planner can push the predicate down and the query above becomes the index lookup you expected. But only when all three hold:
- it is referenced exactly once,
- it is not recursive,
- it has no side effects (no
INSERT/UPDATE/DELETEinside it).
Referenced twice, it is materialised again. That is usually what you want — computing it once and reusing it — but it means the same CTE changes execution strategy when someone adds a second reference, with no other edit.
You can say which you want:
WITH RECURSIVE ok AS MATERIALIZED ( -- force the fence
SELECT ...
), fast AS NOT MATERIALIZED ( -- force inlining
SELECT ...
)MATERIALIZED is the right choice when the CTE is expensive and referenced
several times, or when it calls a volatile function you want evaluated exactly
once. NOT MATERIALIZED is the escape hatch when the planner’s default is
costing you a predicate pushdown.
Confirming which you got
Check, do not assume. The plan tells you which happened:
EXPLAIN ANALYZE
WITH x AS (SELECT * FROM invoice) SELECT * FROM x WHERE id = 12345;A CTE Scan node in the output means it was materialised. No CTE Scan means
it was inlined. See
Reading a query plan.
Other engines differ, and the difference is not cosmetic. SQL Server and Oracle inline CTEs as a rule; MySQL 8 also inlines by default. Porting a query that relied on the fence — a CTE calling a volatile function once, say — will silently change its behaviour rather than fail.
WITH RECURSIVE
The recursive form is a loop, and the syntax hides that badly. It has three parts:
WITH RECURSIVE subordinates AS (
-- 1. anchor: the starting rows, evaluated once
SELECT id, manager_id, name, 1 AS depth
FROM employee
WHERE id = 42
UNION ALL
-- 2. recursive term: joins against the CTE itself
SELECT e.id, e.manager_id, e.name, s.depth + 1
FROM employee e
JOIN subordinates s ON e.manager_id = s.id
)
-- 3. the outer query, run once the loop is finished
SELECT * FROM subordinates ORDER BY depth, name;How it actually executes
The mental model that makes recursive CTEs predictable is the working table:
- Run the anchor. Put its rows in the working table and in the result.
- Run the recursive term, with
subordinatesbound to only the working table — not to everything accumulated so far. - Whatever came back becomes the new working table, and is appended to the result.
- Repeat until step 2 returns no rows.
That step 2 detail is the one people get wrong. The recursive term does not see the full accumulated result; it sees only the previous iteration’s output. A query written as though it can look back at everything found so far will quietly return the wrong rows.
UNION ALL versus UNION follows from this. UNION deduplicates against
the accumulated result on every iteration, which is slower and is sometimes the
only thing stopping a cyclic graph from looping forever. UNION ALL does not
deduplicate — faster, and it will loop forever on a cycle.
Cycles, and the query that never returns
A hierarchy is a tree until someone’s manager field points at their own
subordinate. Then UNION ALL recurses until the server runs out of memory or
disk.
Postgres 14 added a declarative guard:
WITH RECURSIVE subordinates AS (
SELECT id, manager_id, name FROM employee WHERE id = 42
UNION ALL
SELECT e.id, e.manager_id, e.name
FROM employee e JOIN subordinates s ON e.manager_id = s.id
) CYCLE id SET is_cycle USING path
SELECT * FROM subordinates WHERE NOT is_cycle;CYCLE id SET is_cycle USING path tracks the ids visited in path, flags the
row where one repeats, and stops descending there. Before 14, you carried the
path yourself:
SELECT e.id, ..., s.path || e.id
FROM employee e
JOIN subordinates s ON e.manager_id = s.id
WHERE NOT e.id = ANY(s.path) -- the manual cycle guardCarry a depth column and cap it regardless. A cycle guard protects against a loop; a depth cap protects against a hierarchy that is legitimately 400 levels deep and a query that should never have been asked.
Where this goes wrong in production
The unbounded recursion in a request handler. A recursive CTE over a graph has no useful upper bound on work. One malformed row — a cycle introduced by a bad import — turns an endpoint into a query that pins a CPU and holds a connection until the statement timeout fires. If you have no statement timeout, it holds it until someone notices.
Set one, specifically for the endpoints that run recursion:
SET LOCAL statement_timeout = '2s';Recursion where a different data structure belonged. Recursive CTEs are
fine for a few thousand rows and shallow depth. For deep or wide graph
traversal run repeatedly, the cost is real and the alternatives are better: a
materialised path or ltree column for hierarchies that are read far more than
written, a closure table when you need arbitrary ancestor queries, or an actual
graph database when traversal is the workload.
The senior answer to “would you use a recursive CTE for this?” is often no, and the reason is that the shape of the query hides how much work it does.
Related
Interview angle 7
- “Why use a CTE instead of a subquery?” — readability and reuse: each step is named and defined once, so the query reads top to bottom and a shared subquery cannot drift out of sync between two copies.
- Follow-up: “Is it faster?” — no, and that is the wrong instinct. On Postgres 12 and later an inlined CTE plans identically to the equivalent subquery. Before 12 it was reliably slower, because it was an optimisation fence.
- “What is the CTE optimisation fence?” — before Postgres 12 a CTE was always materialised, so the planner could not push outer predicates into it.
WITH x AS (SELECT * FROM huge) SELECT * FROM x WHERE id = 1scanned the whole table instead of using the index.- Follow-up: “When does it still materialise on a current version?” — when the CTE is referenced more than once, is recursive, or contains a data-modifying statement. So adding a second reference can change the plan with no other edit.
- “How would you force one behaviour or the other?” —
AS MATERIALIZEDto keep the fence, for an expensive CTE used several times or one calling a volatile function that must run once;AS NOT MATERIALIZEDwhen you need the predicate pushdown. Confirm withEXPLAIN— aCTE Scannode means materialised. - “Walk me through how a recursive CTE executes.” — the anchor runs once and seeds the working table; the recursive term runs against only the working table, and its output becomes the next working table; it repeats until an iteration returns nothing.
- Follow-up: “So can the recursive term see rows from two iterations ago?” — no, only the previous iteration’s rows. Queries written assuming access to everything accumulated so far return the wrong result.
- “
UNIONorUNION ALLin a recursive CTE?” —UNION ALLis faster because it skips deduplication, but it will loop forever on a cyclic graph.UNIONdeduplicates each iteration and often accidentally terminates the loop, at a cost. - “How do you stop a recursive query from running forever?” — a
CYCLEclause on Postgres 14+, or carry the visited path in an array and filter withNOT id = ANY(path). Add a depth cap and astatement_timeoutregardless, because a cycle guard does not protect you from a legitimately enormous traversal. - “When would you not use a recursive CTE?” — when traversal is the workload rather than an occasional query. A materialised path or
ltreefor read-heavy hierarchies, a closure table for arbitrary ancestor queries, a graph database when the graph is the point. The risk with recursion is that the SQL does not reveal how much work it will do.