The essentials
Quick reference
One focused task per row. Jump to the related section for complete, working examples.
| Use | Syntax | Examples |
|---|---|---|
| Generate a bounded sequence | WITH RECURSIVE t(n) AS (
VALUES (1) UNION ALL
SELECT n + 1
FROM t
WHERE n < 10
)
SELECT n
FROM t; | View examples |
| Walk children | WITH RECURSIVE t(id) AS (
VALUES (42::bigint) UNION ALL
SELECT e.to_id
FROM app.edges e
JOIN t
ON e.from_id=t.id
)
SELECT *
FROM t; | View examples |
| Remove duplicate rows | WITH RECURSIVE t(id) AS (
VALUES (1::bigint) UNION
SELECT to_id
FROM app.edges
JOIN t
ON from_id=t.id
)
SELECT id
FROM t; | View examples |
| Cap traversal depth | WITH RECURSIVE t(n,d) AS (
VALUES (1,0) UNION ALL
SELECT n+1,d+1
FROM t
WHERE d<20
)
SELECT *
FROM t; | View examples |
| Set a statement budget | SET LOCAL statement_timeout = '10s'; | View examples |
| Append to a path | SELECT ARRAY[1,2] || 3; | View examples |
| Test path membership | SELECT 3 = ANY (ARRAY[1,2,3]); | View examples |
| Declare cycle detection | WITH RECURSIVE t(id) AS (
VALUES (1::bigint) UNION ALL
SELECT to_id
FROM app.edges
JOIN t
ON from_id=t.id
) CYCLE id
SET cyc
USING path
SELECT *
FROM t; | View examples |
| Filter cycle-closing rows | SELECT id, path FROM walk WHERE NOT is_cycle; | View examples |
| Create depth-first order | WITH RECURSIVE t(id) AS (
VALUES (1::bigint) UNION ALL
SELECT to_id
FROM app.edges
JOIN t
ON from_id=t.id
) SEARCH DEPTH FIRST BY id
SET ord
SELECT *
FROM t; | View examples |
| Create breadth-first order | WITH RECURSIVE t(id) AS (
VALUES (1::bigint) UNION ALL
SELECT to_id
FROM app.edges
JOIN t
ON from_id=t.id
) SEARCH BREADTH FIRST BY id
SET ord
SELECT *
FROM t; | View examples |
| Order final output | SELECT id FROM tree ORDER BY bfs_order, id; | View examples |
| Count reachable vertices | SELECT count(*) FROM reachable; | View examples |
| Aggregate by depth | SELECT depth, count(*)
FROM walk
GROUP BY depth
ORDER BY depth; | View examples |
| Index outbound edges | CREATE INDEX CONCURRENTLY edges_from_id_idx
ON app.edges (from_id); | View examples |
| Plan without traversal | EXPLAIN
WITH RECURSIVE t(n) AS (
VALUES (1) UNION ALL
SELECT n+1
FROM t
WHERE n<10
)
SELECT *
FROM t; | View examples |
WITH RECURSIVE evaluates an anchor term, then repeatedly feeds newly produced rows into a recursive term until no rows remain. It can express trees, reachability, paths, and iterative calculations, but termination, duplicate semantics, cycle handling, and result ordering must be designed explicitly.
Step by step
Detailed examples
Separate the anchor from the recursive step
The recursive CTE has a non-recursive anchor followed by UNION or UNION ALL and a recursive term that references the CTE once. Types are resolved across both terms, so cast anchor literals to the intended width. UNION ALL avoids duplicate-elimination work but requires another termination guarantee.
WITH RECURSIVE tree AS (
SELECT id, parent_id, name, 0 AS depth
FROM app.categories WHERE id = 42
UNION ALL
SELECT c.id, c.parent_id, c.name, t.depth + 1
FROM app.categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT id, parent_id, name, depth FROM tree ORDER BY depth, id; Make termination a data invariant, not a hopeful LIMIT
A recursive term must eventually return no new rows. Outer LIMIT can help interactive testing because PostgreSQL often pulls rows lazily, but sorts and joins may consume the entire CTE and other systems differ; it is not a production cycle guard. Enforce depth, visited nodes, or CYCLE semantics inside the recursion.
WITH RECURSIVE walk(id, depth) AS (
SELECT 42::bigint, 0
UNION ALL
SELECT e.to_id, w.depth + 1
FROM app.edges e JOIN walk w ON e.from_id = w.id
WHERE w.depth < 20
)
SELECT id, depth FROM walk; Note: A depth cap bounds work but may return a truncated graph; expose that fact to callers.
Track visited vertices for path-specific cycle prevention
An array path can preserve traversal history and reject a vertex already present in the current path. For composite identity, use arrays of ROW values at extra memory and comparison cost. This prevents cycles per path but does not globally deduplicate vertices reached by different paths.
WITH RECURSIVE walk(id, depth, path, is_cycle) AS (
SELECT 42::bigint, 0, ARRAY[42::bigint], false
UNION ALL
SELECT e.to_id, w.depth + 1, w.path || e.to_id, e.to_id = ANY(w.path)
FROM app.edges e JOIN walk w ON e.from_id = w.id
WHERE NOT w.is_cycle
)
SELECT id, depth, path FROM walk WHERE NOT is_cycle; Use SQL CYCLE syntax for declarative detection
CYCLE names the identity columns to track and adds a cycle mark plus a path column. PostgreSQL rewrites it to path-based logic and stops expanding rows after a cycle is detected. The cycle-closing row can remain in output with is_cycle=true, so filter it when consumers expect only acyclic paths.
WITH RECURSIVE walk(id, depth) AS (
SELECT 42::bigint, 0
UNION ALL
SELECT e.to_id, w.depth + 1
FROM app.edges e JOIN walk w ON e.from_id = w.id
) CYCLE id SET is_cycle USING path
SELECT id, depth, path FROM walk WHERE NOT is_cycle
ORDER BY path; Compute display order without confusing it with evaluation order
SEARCH DEPTH FIRST or BREADTH FIRST adds an ordering column; ordering the outer SELECT by that column controls presentation. PostgreSQL recursive evaluation is internally iterative and its emitted order is implementation-dependent. Add deterministic tie-breakers when peers can share equivalent search keys.
WITH RECURSIVE tree(id, parent_id, name) AS (
SELECT id, parent_id, name FROM app.categories WHERE id = 42
UNION ALL
SELECT c.id, c.parent_id, c.name
FROM app.categories c JOIN tree t ON c.parent_id = t.id
) SEARCH BREADTH FIRST BY id SET bfs_order
SELECT id, parent_id, name FROM tree ORDER BY bfs_order, id; Choose whether results represent vertices, edges, or complete paths
UNION removes duplicate complete rows, so adding depth or path can prevent vertex deduplication. For reachability, project only vertex identity in the recursive union; for every path, retain path state and expect combinatorial growth. Weighted shortest paths generally need specialized algorithms or extensions rather than naive exhaustive recursion.
WITH RECURSIVE reachable(id) AS (
VALUES (42::bigint)
UNION
SELECT e.to_id
FROM app.edges e JOIN reachable r ON e.from_id = r.id
)
SELECT id FROM reachable ORDER BY id; Note: UNION makes the finite vertex identity the deduplication key.
Index edges and inspect recursive work at realistic scale
Index the join key used to find the next frontier, such as edges(from_id). EXPLAIN ANALYZE exposes Recursive Union loops, rows, buffers, and spills, but executes the traversal. A statement sees one MVCC snapshot, so concurrent committed graph changes normally are not mixed into a single read; SERIALIZABLE may still abort on conflicts.
EXPLAIN (ANALYZE, BUFFERS)
WITH RECURSIVE walk(id, depth) AS (
VALUES (42::bigint, 0)
UNION ALL
SELECT e.to_id, w.depth + 1 FROM app.edges e JOIN walk w ON e.from_id = w.id
WHERE w.depth < 10
)
SELECT count(*) FROM walk; Note: Use a read-only transaction and representative graph; watch loops, row growth, temp I/O, and timeout behavior.
Sources and further reading
References
Authoritative documentation used to verify and expand this cheat sheet.
- PostgreSQL Global Development GroupWITH Queriespostgresql.org
- PostgreSQL Global Development GroupSELECTpostgresql.org
- PostgreSQL Global Development GroupSorting Rowspostgresql.org
- PostgreSQL Global Development GroupArray Functions and Operatorspostgresql.org
- PostgreSQL Global Development GroupEXPLAINpostgresql.org
- PostgreSQL Global Development GroupIntroduction to MVCCpostgresql.org
- PostgreSQL Global Development GroupIndex Typespostgresql.org
Help us improve
Found a typo or missing example?
Tell us what would make this cheat sheet clearer, more complete, or more useful.



