28. Recursive CTEs: hierarchies and graphs
The problem plain SQL can't express
Every query so far operates on a fixed, known set of tables. But
some questions require an unknown number of steps: "all subordinates
under this manager, at any depth," "every category reachable from this
one via sub-categories," "every day between two dates." Plain JOIN
can't express "repeat this join an unknown number of times" — you'd need
to write a different query for depth 1, depth 2, depth 3... A recursive
CTE is SQL's answer: a WITH block that references itself.
The anatomy of a recursive CTE
WITH RECURSIVE cte_name AS (
-- 1. Base case: the starting row(s), no self-reference here
SELECT ...
UNION ALL
-- 2. Recursive case: references cte_name itself, building on prior results
SELECT ...
FROM cte_name
JOIN some_table ON ...
)
SELECT * FROM cte_name;
Execution, conceptually: run the base case once to produce an initial result set. Then repeatedly run the recursive case, feeding in only the rows produced by the previous iteration (not the whole accumulated set each time), appending new rows to the total result — until an iteration produces zero new rows, at which point recursion stops. This "feed only the newest rows back in" detail is what makes it recursion rather than an infinite unbounded self-join, and it's the part people most often get wrong when hand-simulating what a recursive CTE does.
UNION ALL (not UNION) is standard between the two parts — using
UNION would force a full duplicate-check across the whole accumulated
result on every iteration, expensive and usually unnecessary since the
recursion structure itself typically prevents exact duplicate rows.
Example 1: a number series (the simplest possible recursive CTE)
WITH RECURSIVE counter AS (
SELECT 1 AS n -- base case: start at 1
UNION ALL
SELECT n + 1 FROM counter WHERE n < 10 -- recursive case: +1 each time, stop at 10
)
SELECT n FROM counter;
This produces the numbers 1 through 10. Trace it by hand once: base case
gives {1}. Iteration 1 takes {1}, produces {2} (since 1 < 10).
Iteration 2 takes {2}, produces {3}. ... Iteration 9 takes {9},
produces {10}. Iteration 10 takes {10}, the WHERE n < 10 condition
is now false, produces nothing — recursion stops. Ten total rows
accumulated across all iterations.
(For this specific use case — a number series — Postgres's built-in
generate_series(1, 10) is simpler and what you'd actually use in real
code. The recursive version above exists purely to demonstrate the
mechanics on the simplest possible case before tackling a real
hierarchy.)
Example 2: hierarchy traversal, using pagila's category structure
pagila's category table is flat (no parent-category column), so this
lesson builds a small hypothetical hierarchy to demonstrate the pattern
properly — the shape below is exactly what you'd use for a real
employee.manager_id self-reference or a genuine nested-category schema:
CREATE TEMP TABLE category_tree (
category_id integer PRIMARY KEY,
name text,
parent_id integer REFERENCES category_tree(category_id)
);
INSERT INTO category_tree VALUES
(1, 'Media', NULL),
(2, 'Film', 1),
(3, 'Music', 1),
(4, 'Action Film', 2),
(5, 'Comedy Film', 2);
WITH RECURSIVE tree AS (
-- Base case: root nodes (no parent)
SELECT category_id, name, parent_id, 0 AS depth
FROM category_tree
WHERE parent_id IS NULL
UNION ALL
-- Recursive case: children of whatever was found in the previous step
SELECT c.category_id, c.name, c.parent_id, t.depth + 1
FROM category_tree c
JOIN tree t ON c.parent_id = t.category_id
)
SELECT repeat(' ', depth) || name AS indented_name, depth
FROM tree
ORDER BY depth, name;
The depth column is a common, useful addition — computed as 0 in
the base case and parent's depth + 1 in the recursive case, giving you
"how deep in the tree is this row" essentially for free, useful both for
display (indentation, as above) and as a safety bound.
Guarding against runaway recursion
A cyclic graph (A → B → A) would recurse forever without a guard, since nothing would ever produce zero new rows. Two standard defenses:
- A depth limit, as a
WHERE depth < Nin the recursive term — simple, always works, requires picking a sensible max depth. - Cycle detection, tracking visited nodes in an array and checking
NOT (next_node = ANY(visited_path))before adding a new row — more precise, standard for genuinely graph-shaped (not strictly-hierarchical) data.
Postgres also enforces a hard safety valve: WITH RECURSIVE has no
built-in row limit by default, so an actual infinite recursion will
exhaust memory/time — always test a new recursive CTE against small,
known-bounded data first, and add an explicit depth guard for anything
touching data you don't fully control the shape of.
Check yourself
- Why does the recursive term use
UNION ALLrather thanUNIONby convention? - In the number-series example, trace by hand: what row(s) does iteration 3 receive as input, and what does it produce as output?
- What are the two standard defenses against infinite recursion on cyclic data, and when would you reach for each?