open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 05 · 04

Recursive CTEs: anchor, iterate, stop

WITH RECURSIVE is an anchor term UNION a recursive term that re-reads the CTE — Postgres runs it as an iterative working-table loop until the recursive term yields nothing, so termination and cycle protection are on you.

SQL Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

Someone asks “give me everyone under Ada in the org chart, however deep.” You can’t write that with joins — the depth is unknown, and SQL’s JOIN count is fixed at parse time. The honest options used to be a recursive function in the app or a stored procedure with a loop. Then you remember WITH RECURSIVE, write twelve lines, and Postgres walks the whole subtree for you. But the first time you run it on a table with a cycle, it doesn’t return — it loops until it fills the disk. Recursion in SQL is a real loop, and loops need a brake.

Anatomy: anchor UNION recursive term

A recursive CTE has a fixed shape. The keyword is WITH RECURSIVE, and the CTE body is two queries combined by UNION or UNION ALL:

  • the anchor term — a non-recursive query that produces the starting rows (the base case);
  • the recursive term — a query that references the CTE by its own name, producing the next rows from the previous ones.

Here is “all reports under Ada, with depth” against employees(id, manager_id, name):

WITH RECURSIVE subtree AS (
  -- anchor: start at Ada
  SELECT id, manager_id, name, 1 AS depth
  FROM employees
  WHERE name = 'Ada'

  UNION ALL

  -- recursive term: one level down each pass
  SELECT e.id, e.manager_id, e.name, s.depth + 1
  FROM employees e
  JOIN subtree s ON e.manager_id = s.id
)
SELECT id, name, depth FROM subtree ORDER BY depth, id;

The recursive term joins employees to subtree — the CTE referencing itself — which is the one self-reference lesson 01 said was legal only here.

The working-table execution model

Postgres does not evaluate this with mathematical recursion or function calls. It runs an iterative loop over a working table, and understanding that model is the difference between writing recursive SQL by ritual and debugging it under load:

  1. Evaluate the anchor term. Put its rows into the result and into the working table.
  2. Repeat: evaluate the recursive term, but with each self-reference to the CTE reading only the working table (the rows from the previous iteration, not the whole accumulated result). Append the new rows to the result; they become the new working table.
  3. Stop when an iteration produces zero new rows.

Together these three steps mean each pass only ever sees fresh frontier rows, never the whole accumulating result — which is why you must carry any state you need (depth, path) inside the row itself. Without step 3’s termination condition, an infinite source of new rows means an infinite loop.

That termination rule is the whole game. The loop ends when the recursive term yields nothing — so if it always yields something, the loop never ends.

Termination, cycles, and the brake

In a clean tree (every node has one parent, no loops), recursion terminates naturally: eventually you reach the leaves and the recursive term finds no children. The danger is a cycle — a row whose chain of parents eventually points back to itself, common in edges(src, dst) graph data or a corrupted manager_id. With UNION ALL and a cycle, the loop revisits the same rows forever and the query never returns; it spins until it exhausts work_mem, spills to disk, and eventually errors or hangs. You need an explicit brake. Three options:

-- Option 1 (PG14+): the CYCLE clause does it declaratively.
WITH RECURSIVE walk AS (
  SELECT src, dst FROM edges WHERE src = 1
  UNION ALL
  SELECT e.src, e.dst FROM edges e JOIN walk w ON e.src = w.dst
)
CYCLE dst SET is_cycle USING path        -- stop revisiting a dst already on the path
SELECT * FROM walk WHERE NOT is_cycle;

-- Option 2 (any version): carry a visited-array and exclude nodes already on the path.
WITH RECURSIVE walk AS (
  SELECT src, dst, ARRAY[src] AS path FROM edges WHERE src = 1
  UNION ALL
  SELECT e.src, e.dst, w.path || e.src
  FROM edges e JOIN walk w ON e.src = w.dst
  WHERE NOT e.src = ANY(w.path)           -- don't re-enter a visited node
)
SELECT * FROM walk;

-- Option 3 (a hard cap): carry depth and bound it.
... WHERE s.depth < 50

UNION (not UNION ALL) also dedupes each pass against the accumulated result, which stops simple repeats — but it’s slower and won’t catch all graph cycles, so on real graph data prefer the explicit CYCLE clause (Postgres 14+) or a visited-array. A depth cap is the seatbelt you add regardless: even with cycle protection, an unbounded walk over a huge graph can return millions of rows, so bound the depth to something your use case actually needs.

Edge cases

A subtle gotcha: the recursive term sees only the previous iteration’s rows, not the whole result so far. That’s why you can’t reference an aggregate over the entire accumulating CTE inside the recursive term, and why running totals across the recursion need the value carried forward row-by-row (e.g. s.depth + 1, or w.path || e.src) rather than computed by looking back at everything. If you find yourself wanting to “see all rows collected so far” mid-recursion, you’ve hit the boundary of the working-table model — restructure to carry the state you need in the row itself.

Quiz

In WITH RECURSIVE, what does each pass of the recursive term actually read from when it references the CTE by name?

Quiz

A recursive CTE over edges(src, dst) with UNION ALL hangs and never returns on real graph data. Most likely cause and fix?

Order the steps

Order how Postgres executes a recursive CTE:

  1. 1 Evaluate the anchor term; put its rows in the result and the working table
  2. 2 Evaluate the recursive term against only the current working table
  3. 3 Append the new rows to the result; they become the new working table
  4. 4 Repeat the recursive term on the refreshed working table
  5. 5 Stop when an iteration produces zero new rows
Recall before you leave
  1. 01
    What are the two parts of a recursive CTE and how are they combined?
  2. 02
    Describe the working-table execution model and the termination rule.
  3. 03
    Why can a recursive CTE hang, and what are the ways to prevent it?
Recap

WITH RECURSIVE finally lets pure SQL walk structures of unknown depth — org charts, category trees, graphs — that fixed JOINs can’t express. Its anatomy is two queries joined by UNION/UNION ALL: an anchor term that seeds the base rows, and a recursive term that references the CTE by its own name to derive the next rows from the previous ones. Postgres executes this not as mathematical recursion but as an iterative working-table loop: evaluate the anchor into the result and the working table, then repeat the recursive term — each pass reading only the previous iteration’s rows — appending and replacing the working table until a pass yields zero new rows, which is the implicit termination condition. That rule is also the hazard: on a cycle, the recursive term never stops producing rows, so the query loops until it exhausts memory. The brakes are an explicit CYCLE clause (Postgres 14+), a carried visited-array with WHERE NOT node = ANY(path), UNION to dedupe simple repeats, and a depth cap as a seatbelt you add regardless. Remember the model’s boundary: the recursive term sees only the prior pass, never the whole accumulating result, so carry forward any state you need in the row. Next, lesson 05 turns this engine on real graphs — paths, DAGs, and where SQL recursion stops being the right tool. Now when you see a recursive CTE that hangs under load, you’ll check the working-table model first: is there a cycle, and is the brake in place?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.