On this page
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:
WITHfor readable, step-by-step queries, and how Postgres runs it.WITH RECURSIVE- walking an org chart of any depth, with depth and path.- Generating sequences with recursion.
- Graphs with cycles, and how to stop a recursion that would run forever.
- 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;
| name | direct_reports |
|---|---|
| Ava | 2 |
| Liam | 2 |
| Noah | 1 |
| Sofia | 1 |
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):
- The anchor runs once and produces the starting rows.
- 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.
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;
| name | depth |
|---|---|
| Emma | 1 |
| Noah | 1 |
| Lucas | 2 |
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:
UNIONinstead ofUNION ALLremoves 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;
| node | path |
|---|---|
| 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 need | Use |
|---|---|
| Readable steps in one long query | WITH (non-recursive) |
| A step computed once and used several times | WITH ... AS MATERIALIZED, or rely on Postgres materializing a CTE referenced twice |
| Everything under (or above) a node, depth unknown | WITH RECURSIVE over a parent_id column |
| Reachability in a graph that may have cycles | WITH RECURSIVE with UNION, or a path array / CYCLE clause |
| A sequence of numbers or dates | generate_series |
| A fixed, known number of levels (employee and their manager) | A plain self-join |
| Fast subtree reads on a big, read-heavy tree | Another 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 withUNION, a path check,CYCLE, or a maximum depth. - Fixed self-joins for a hierarchy -
e1 JOIN e2 JOIN e3handles 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.idwalks down;e.id = t.manager_idwalks 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_idmakes moves and inserts trivial and reads need recursion; a materialized path ('1/2/4', or theltreeextension) 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. UNIONvsUNION ALLin recursion:UNIONdeduplicates each round (cycle-safe for simple rows, but slower);UNION ALLis 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
- 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.
- Why can
WITH RECURSIVEover afollowsgraph run forever withUNION ALLbut finish withUNION?
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.)
- 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
WITHnames query steps for one statement; Postgres 12+ inlines single-use CTEs and materializes multi-use ones (override withMATERIALIZED/NOT MATERIALIZED).WITH RECURSIVE= anchor, then a recursive term run on the previous round's rows until a round adds nothing.- Track
depthandpathin the recursive term; walk down withchild.parent_id = t.id, up witht.parent_id = parent.id. - Cycles need a guard:
UNION, a path array check, theCYCLEclause, or a depth limit. - Fixed self-joins only handle a fixed depth - hierarchies of unknown depth need recursion.