Analytical SQL · CTEs and Recursive Queries

CTEs and Recursive Queries

Naming query steps with WITH, and WITH RECURSIVE for org charts, category trees and series - with a guard against cycles.

  • 11 min read
  • 12 practice questions
  • 3 graded problems
On this page
  1. Description
  2. WITH: name the steps
  3. WITH RECURSIVE
  4. Cycles
  5. Which tool?
  6. Where it breaks
  7. Tradeoffs
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

Long SQL gets hard to read fast: a subquery inside a subquery inside a join, read from the inside out. WITH fixes that. It lets you name each step of a query - "daily revenue", then "with the previous day", then "only the drops" - so the query reads top to bottom like a short story. These named steps are called common table expressions (CTEs).

WITH has a second, more powerful form. Some questions have no fixed depth: "everyone in Liam's organization, however many levels down", "every category under Electronics", "everyone reachable from this user". No fixed number of joins answers them. WITH RECURSIVE does - a query that keeps applying itself to its own results until nothing new turns up. We'll cover:

  1. WITH for readable, step-by-step queries, and how Postgres runs it.
  2. WITH RECURSIVE - walking an org chart of any depth, with depth and path.
  3. Generating sequences with recursion.
  4. Graphs with cycles, and how to stop a recursion that would run forever.
  5. Which tool to use for which question.

Examples use seven employees - Ava at the top, Liam and Sofia under her, Liam's team two levels deep - and a small follows graph with a cycle (1 → 2 → 3 → 1, and 3 → 4). Every result was produced by PostgreSQL 18.

WITH: name the steps

A CTE gives a query a name for the length of one statement:

WITH reports AS (
    SELECT manager_id, COUNT(*) AS direct_reports
    FROM employees
    WHERE manager_id IS NOT NULL
    GROUP BY manager_id
)
SELECT e.name, r.direct_reports
FROM reports r
JOIN employees e ON e.id = r.manager_id
ORDER BY r.direct_reports DESC, e.name;
namedirect_reports
Ava2
Liam2
Noah1
Sofia1

Each CTE can use the ones before it, so a long analysis reads top to bottom as named steps - "daily revenue", then "with the previous day", then "only the drops" - instead of inside out.

How Postgres runs it. Since PostgreSQL 12, a CTE referenced once (with no side effects) is inlined - planned exactly as if you'd written the subquery in place, so filters from the outer query can be pushed into it. A CTE referenced more than once is materialized: computed once, stored, and read by each reference. You can force either with WITH x AS MATERIALIZED (...) or AS NOT MATERIALIZED - materializing is useful to stop an expensive step running twice, harmful when it prevents the planner from using an index for a filter applied outside.

Writes in CTEs. A WITH step can be INSERT, UPDATE or DELETE ... RETURNING, feeding the rows it touched into the next step - for example WITH moved AS (DELETE FROM orders WHERE ordered_at < '2025-01-01' RETURNING *) INSERT INTO orders_archive SELECT * FROM moved; archives in one atomic statement. All the steps see the same snapshot of the data, so a step can't read another step's changes to a table - only its RETURNING rows.

WITH RECURSIVE

A recursive CTE has two parts joined by UNION ALL (or UNION):

  1. The anchor runs once and produces the starting rows.
  2. The recursive term refers to the CTE itself. It runs on the rows produced by the previous round, again and again, until a round produces no new rows.

yes: add them to the result,
they become the working table

no

Anchor query
runs once

Working table
rows from the last round

Recursive term
joins the working table

New rows?

Done: return
every round's rows

yes: add them to the result,
they become the working table

no

Anchor query
runs once

Working table
rows from the last round

Recursive term
joins the working table

New rows?

Done: return
every round's rows

WITH RECURSIVE team AS (
    SELECT id, name, 1 AS depth                -- anchor: Liam's direct reports
    FROM employees
    WHERE manager_id = 2
  UNION ALL
    SELECT e.id, e.name, t.depth + 1           -- step: reports of the rows found so far
    FROM employees e
    JOIN team t ON e.manager_id = t.id
)
SELECT name, depth FROM team ORDER BY depth, name;
namedepth
Emma1
Noah1
Lucas2

Ava

Liam

Sofia

Noah
round 1, depth 1

Emma
round 1, depth 1

Lucas
round 2, depth 2

Mia

Ava

Liam

Sofia

Noah
round 1, depth 1

Emma
round 1, depth 1

Lucas
round 2, depth 2

Mia

Round one finds Noah and Emma; round two finds Lucas (Noah's report); round three finds nobody, and the recursion stops. Counting depth in the recursive step tells you how far below Liam each person sits. Note the anchor starts from Liam's reports - start from WHERE id = 2 instead if Liam should be in the result himself.

Carrying a value down the recursion builds paths - here the reporting chain from the top:

WITH RECURSIVE chain AS (
    SELECT id, name::text AS path FROM employees WHERE manager_id IS NULL
  UNION ALL
    SELECT e.id, c.path || ' > ' || e.name
    FROM employees e
    JOIN chain c ON e.manager_id = c.id
)
SELECT path FROM chain ORDER BY path;
path
Ava
Ava > Liam
Ava > Liam > Emma
Ava > Liam > Noah
Ava > Liam > Noah > Lucas
Ava > Sofia
Ava > Sofia > Mia

Recursion isn't only for trees - it can generate sequences:

WITH RECURSIVE n AS (
    SELECT 1 AS i
  UNION ALL
    SELECT i + 1 FROM n WHERE i < 5
)
SELECT i FROM n;
i
1
2
3
4
5

(For number and date sequences, generate_series(1, 5) is simpler and faster - the time-series module uses it.)

Cycles

An org chart is a tree, so the recursion ends at the leaves. A general graph - followers, flight routes, a bill of materials with a mistake in it - can loop. With UNION ALL, a cycle produces new rows forever, until the statement timeout kills it. Two guards:

  • UNION instead of UNION ALL removes rows already produced, so the recursion stops once nothing new is reached. That works when each row is just "a node":
WITH RECURSIVE reach(node) AS (
    SELECT 1
  UNION                                        -- not UNION ALL: stop when nothing new is found
    SELECT f.b FROM follows f JOIN reach r ON f.a = r.node
)
SELECT node FROM reach ORDER BY node;
node
1
2
3
4
  • A path array, when rows carry more (depth, path), so rows are never exact duplicates: stop extending a path when the next node is already on it.
WITH RECURSIVE walk AS (
    SELECT 1 AS node, ARRAY[1] AS path
  UNION ALL
    SELECT f.b, w.path || f.b
    FROM walk w
    JOIN follows f ON f.a = w.node
    WHERE NOT f.b = ANY (w.path)               -- never revisit a node on this path
)
SELECT node, path FROM walk ORDER BY cardinality(path), node;
nodepath
1{1}
2{1,2}
3{1,2,3}
4{1,2,3,4}

PostgreSQL 14+ also has a CYCLE clause that maintains that path and a cycle flag for you, and a SEARCH clause that orders results depth-first or breadth-first.

Which tool?

You needUse
Readable steps in one long queryWITH (non-recursive)
A step computed once and used several timesWITH ... AS MATERIALIZED, or rely on Postgres materializing a CTE referenced twice
Everything under (or above) a node, depth unknownWITH RECURSIVE over a parent_id column
Reachability in a graph that may have cyclesWITH RECURSIVE with UNION, or a path array / CYCLE clause
A sequence of numbers or datesgenerate_series
A fixed, known number of levels (employee and their manager)A plain self-join
Fast subtree reads on a big, read-heavy treeAnother encoding: materialized path or closure table (see the deep dive)

Where it breaks

  • A recursive CTE over data with a cycle and UNION ALL - runs until the timeout. Guard with UNION, a path check, CYCLE, or a maximum depth.
  • Fixed self-joins for a hierarchy - e1 JOIN e2 JOIN e3 handles exactly three levels; the fourth level silently disappears.
  • The anchor includes (or excludes) the root by accident - read the requirement: "Liam's organization" may or may not mean Liam.
  • A CTE used to "cache" a result that is referenced once - since Postgres 12 it's inlined, not cached.
  • Recursing from children to parents vs parents to children - the join direction (e.manager_id = t.id walks down; e.id = t.manager_id walks up) decides whether you get descendants or ancestors.

Tradeoffs

  • CTE vs subquery: identical plans in modern Postgres when referenced once; the CTE is easier to read and test step by step.
  • Adjacency list + recursive CTE vs other tree encodings: storing just parent_id makes moves and inserts trivial and reads need recursion; a materialized path ('1/2/4', or the ltree extension) or a closure table makes subtree reads a simple indexed lookup at the cost of harder writes. For org charts and categories with thousands of rows, recursion is fast enough.
  • UNION vs UNION ALL in recursion: UNION deduplicates each round (cycle-safe for simple rows, but slower); UNION ALL is faster and right for trees.

Going further. The deep dive (next tab) shows the plan of a recursive query and what makes it fast or slow, CTE inlining versus materialization with real plans, the SEARCH and CYCLE clauses, writable CTEs and their snapshot, four ways to store a tree, and why graph queries in SQL can explode.

Check yourself

  1. A recursive CTE has an anchor and a recursive term. When does it stop?
Show answer

When a round of the recursive term produces no new rows. Each round only sees the rows the previous round produced.

  1. Why can WITH RECURSIVE over a follows graph run forever with UNION ALL but finish with UNION?
Show answer

A cycle keeps producing the same nodes again. UNION ALL keeps them, so every round has "new" rows; UNION discards rows already produced, so once every reachable node has been seen a round adds nothing and the recursion ends. (If rows carry a depth or path they're never exact duplicates - use a path check instead.)

  1. You wrote WITH totals AS (...) SELECT ... FROM totals WHERE region = 'EU' to "compute totals once". Is it computed once, before the filter?
Show answer

No - referenced once, a CTE is inlined in Postgres 12+, so it's planned like a subquery and the region = 'EU' filter can be pushed into it (which is usually what you want). AS MATERIALIZED forces it to be computed once, separately.

Key takeaways

  • WITH names query steps for one statement; Postgres 12+ inlines single-use CTEs and materializes multi-use ones (override with MATERIALIZED / NOT MATERIALIZED).
  • WITH RECURSIVE = anchor, then a recursive term run on the previous round's rows until a round adds nothing.
  • Track depth and path in the recursive term; walk down with child.parent_id = t.id, up with t.parent_id = parent.id.
  • Cycles need a guard: UNION, a path array check, the CYCLE clause, or a depth limit.
  • Fixed self-joins only handle a fixed depth - hierarchies of unknown depth need recursion.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You needUse
Readable steps in one long queryWITH
A step computed once and used several timesWITH x AS MATERIALIZED (...) (Postgres also materializes a CTE referenced twice)
Everything under (or above) a node, depth unknownWITH RECURSIVE over a parent_id column
Reachability in a graph that may loopWITH RECURSIVE + UNION, a path array, or CYCLE
Numbers or dates in a rangegenerate_series
A fixed number of levels (employee + manager)A plain self-join
Fast subtree reads on a big treeMaterialized path (ltree) or a closure table

How a recursive CTE runs

  1. The anchor runs once.
  2. The recursive term runs on the rows the previous round produced (the work table), again and again.
  3. It stops when a round adds no new rows.

Walk down: JOIN team t ON e.manager_id = t.id. Walk up: JOIN chain c ON e.id = c.manager_id.

Snippets worth knowing by heart

-- everyone under Liam (id 2), with depth
WITH RECURSIVE team AS (
    SELECT id, name, 1 AS depth FROM employees WHERE manager_id = 2
  UNION ALL
    SELECT e.id, e.name, t.depth + 1
    FROM employees e JOIN team t ON e.manager_id = t.id
)
SELECT * FROM team;

-- the path from the root: the two parts of WITH RECURSIVE chain AS (...)
SELECT id, name::text AS path FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, c.path || ' > ' || e.name FROM employees e JOIN chain c ON e.manager_id = c.id

-- cycle-safe reachability: UNION drops rows already seen
WITH RECURSIVE reach(node) AS (
    SELECT 1
  UNION
    SELECT f.b FROM follows f JOIN reach r ON f.a = r.node
) SELECT node FROM reach;

-- never revisit a node on this path
WHERE NOT f.b = ANY (w.path)

-- archive in one atomic statement
WITH moved AS (DELETE FROM orders WHERE ordered_at < '2025-01-01' RETURNING *)
INSERT INTO orders_archive SELECT * FROM moved;

The traps

  • UNION ALL over a graph with a cycle runs until the timeout. Guard with UNION, a path check, CYCLE or a depth limit.
  • Fixed self-joins for a hierarchy silently drop the levels below the last join.
  • The anchor includes or excludes the root by accident - "Liam's organization" may or may not mean Liam.
  • A CTE to "cache" a result referenced once - since Postgres 12 it's inlined, not cached.
  • The wrong join direction gives ancestors instead of descendants.
  • Reading your own change from the table inside a writable CTE - use the RETURNING rows.

Staff-level points, one line each

  • The plan: Recursive Union over a WorkTable Scan (last round's rows only). Each round should be an index lookup - walking down needs an index on parent_id; without it every round scans the table.
  • PostgreSQL 12+ inlines a CTE referenced once (outer filters push in, indexes apply); MATERIALIZED computes it in full first. Pre-12 advice about CTEs as "optimization fences" is stale.
  • PostgreSQL has no recursion depth limit (SQL Server stops at 100 by default, MySQL at 1,000); add a depth column and statement_timeout anyway.
  • SEARCH BREADTH FIRST / DEPTH FIRST BY id SET ord (14+) orders the traversal; CYCLE node SET is_cycle USING path stops loops for you.
  • A data-modifying CTE runs exactly once even if nothing references it.
  • The whole statement shares one snapshot: the main query doesn't see a CTE's update; RETURNING hands over the new values. Two CTEs updating the same row is undefined.
  • Tree encodings: adjacency list (easy writes, recursive reads), materialized path (prefix-match reads, rewrite on move), closure table (one lookup per subtree, a row per ancestor pair), nested sets (renumber on insert - mostly historical).
  • Carrying a path enumerates every simple path - factorial in dense graphs (13,700 paths from one node of an 8-node complete graph). For reachability or shortest hops, deduplicate per node (BFS); for real graph work, pgRouting, Apache AGE or a graph database.

Say it in an interview

  • "Adjacency list with a recursive CTE, and an index on parent_id so each level is an index lookup."
  • "That recursion never ends because of a cycle - UNION on the node, or the CYCLE clause, plus a depth limit."
  • "A single-use CTE is inlined since Postgres 12, so the filter still reaches the index."
  • "For friends-of-friends I'd do a BFS with per-node dedup and a hop limit, not path enumeration."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice CTEs and Recursive Queries free

Everyone Under a Manager

MediumProSQL problem

An access review needs everyone in the organization of the employee with id 2 - their direct reports, their reports' reports, and so on, however deep it goes. Employee 2 isn't part of the result.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Category Tree Totals

HardProSQL problem

The store's categories form a tree: a top-level category (no parent_id) contains subcategories, which can contain their own, to any depth. Products sit in any category, at any level.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice CTEs and Recursive Queries free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime