Recursive CTEs in PostgreSQL
Learn to run recursive queries with CTEs in PostgreSQL. Master the WITH RECURSIVE syntax, walk through a hands-on example, and see how to troubleshoot common pitfalls.
Focus: run recursive queries with ctes
You know the feeling: you need to list every employee in an org chart, every part in a product structure, or every node in a comment thread — and the obvious SQL just … stops. Joins flatten data, but hierarchical data isn't flat. Without the right tool, you end up writing recursive application code, firing query after query, or maintaining painful nested loops. That's exactly the pain this lesson solves: running recursive queries with CTEs in PostgreSQL, using the powerful WITH RECURSIVE clause to turn a single, readable query into a tree-walking engine.
The problem this lesson solves
Hierarchical data is everywhere: employee reporting lines, category trees, file systems, comment reply chains, and bill-of-materials structures. A plain JOIN can only reach one level deep at a time. To climb deeper, you'd write LEFT JOIN after LEFT JOIN until your query is a vertical cliff of repeated joins — and even then, it breaks the moment the depth varies. This is the classic "how many levels deep?" problem, and it's the reason recursive CTEs exist. Without them, developers often fall back to fetching data in loops, which is slow, chatty, and hard to maintain. Your database can do this traversal natively — you just need to know the syntax.
Core concept / mental model
Think of a recursive CTE as a loop written in SQL. It has two halves that mirror a function with a base case and a recursive call:
- Anchor member: the starting rows — the "base case" that seeds the loop.
- Recursive member: the query that references the CTE itself, discovering the next level of rows.
Each iteration takes the rows from the previous iteration, runs the recursive member against them, and adds new rows to the result. The loop repeats until the recursive member returns zero rows. A UNION ALL (or UNION) combines the anchor and recursive results, and a LIMIT or depth guard keeps runaway loops in check.
A diagram-in-words: imagine dropping a stone into a well. The anchor member is the stone hitting the water — your starting row. Each splash creates ripples (recursive iterations) that spread outward and trigger the next ripple, until the well is calm (no more rows).
The syntax looks like this:
WITH RECURSIVE cte_name AS (
-- Anchor member
SELECT ...
UNION ALL
-- Recursive member (references cte_name)
SELECT ...
)
SELECT * FROM cte_name;
The RECURSIVE keyword is what turns a regular CTE into a self-referencing loop. Without it, PostgreSQL throws an error if you try to reference the CTE name inside its own body.
How it works step by step
Let's unpack the mechanics with a concrete scenario: an employee table where each row has an id, a name, and a manager_id pointing to the manager's row. This is the classic org-chart case.
- Identify the starting point(s) — usually rows with a
NULLparent (the CEO) or a specific node (e.g., an employee whose team you want). - Write the anchor query —
SELECTthose starting rows. These seed the CTE. - Write the recursive query —
SELECTthe children of the current iteration by joining the CTE's output to the parent column. This is where the magic happens: the CTE "sees" its own previous results. - Combine with
UNION ALL— this appends each iteration's rows. UseUNIONif you need to deduplicate (slower, usually unnecessary). - Add a depth column — increment a depth counter each iteration to track levels. This is invaluable for debugging and for limiting depth.
- Add a termination guard — in a tree without cycles, the loop naturally ends when no children remain. But if your data has cycles (e.g., two employees manage each other), you need a
LIMITor adepth < Xcondition to avoid infinite loops.
The sequence is: anchor rows are produced first, then iteration 1 runs the recursive member against them, producing iteration 2's rows, and so on. The final result is the union of all iterations.
Hands-on walkthrough
Let's build a realistic example. First, create a table and seed it with data:
CREATE TABLE employees (
id INT PRIMARY KEY,
name TEXT NOT NULL,
manager_id INT REFERENCES employees(id)
);
INSERT INTO employees (id, name, manager_id) VALUES
(1, 'Alice', NULL), -- CEO
(2, 'Bob', 1),
(3, 'Charlie', 1),
(4, 'Diana', 2),
(5, 'Eve', 2),
(6, 'Frank', 3),
(7, 'Grace', 4);
Now, run a recursive query to list everyone under Alice, including their depth:
WITH RECURSIVE org_chart AS (
-- Anchor: the top of the tree
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: children of the previous iteration
SELECT e.id, e.name, e.manager_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart ORDER BY depth, id;
Output:
id | name | manager_id | depth
----+---------+------------+-------
1 | Alice | | 0
2 | Bob | 1 | 1
3 | Charlie | 1 | 1
4 | Diana | 2 | 2
5 | Eve | 2 | 2
6 | Frank | 3 | 2
7 | Grace | 4 | 3
Notice how the query automatically walks all levels — no hardcoded joins. Now let's find the path from Alice to Grace using a path column:
WITH RECURSIVE paths AS (
SELECT id, name, manager_id, name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, p.path || ' → ' || e.name
FROM employees e
JOIN paths p ON e.manager_id = p.id
)
SELECT * FROM paths WHERE id = 7;
Output:
id | name | manager_id | path
----+-------+------------+------------------------------
7 | Grace | 4 | Alice → Bob → Diana → Grace
This is the power of running recursive queries with CTEs — you get deep hierarchy and paths in one pass.
Adding cycle protection
If your data could contain cycles, add a depth guard:
WITH RECURSIVE org_chart AS (
SELECT id, name, manager_id, 1 AS depth, ARRAY[id] AS path
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, oc.depth + 1, oc.path || e.id
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
WHERE oc.depth < 10 -- hard limit
AND NOT e.id = ANY(oc.path) -- cycle detection
)
SELECT * FROM org_chart;
The path array tracks visited nodes; the WHERE NOT e.id = ANY(oc.path) stops cycles from repeating.
Pro tip: Always include a depth limit or cycle guard in production recursive queries. Even if your data is a clean tree today, one bad row can turn your query into an infinite loop that wrecks performance.
Compare options / when to choose what
Recursive CTEs aren't the only way to handle hierarchies. Here's a comparison:
| Approach | Strengths | Weaknesses | Best for |
|---|---|---|---|
| Recursive CTE | Single query, clean, declarative, no app logic | Can be slow on huge trees (depth > 1000), risk of infinite loops | Depth-unknown trees, ad-hoc queries, small-to-medium data |
| Nested set model | Fast reads of whole subtrees | Complex writes, hard to maintain | Read-heavy trees with rare updates |
| Adjacency list + app recursion | Simple table, flexible | N+1 queries, chatty, logic in code | Tiny trees or when you need app-side processing |
| Materialized path | Easy path queries, indexable | Manual path maintenance | Read-mostly, path-heavy queries |
Recursive CTEs shine when you need an occasional deep dive into a tree and you don't want to maintain a denormalized structure. For very large, hot traversals, consider a materialized path or nested sets.
Variations you might see
UNIONvsUNION ALL:UNIONdeduplicates rows across iterations, which can hide cycles but slows performance. UseUNION ALLunless you have duplicate rows.- Multiple anchor members: You can combine two
SELECTs before theUNION ALLto seed from multiple starting points (e.g., two top managers). - Recursive CTE in
INSERT/UPDATE: You can use a recursive CTE as a data source forINSERT … SELECT, useful for building denormalized tables.
Troubleshooting & edge cases
Here are the most common failures and fixes:
Error: "recursive reference to query 'cte' must not appear within its non-recursive term"
You referenced the CTE in the anchor member. Only the recursive member (after UNION ALL) may reference the CTE name.
Infinite loop / query never ends
Your data has a cycle or your recursive member keeps finding rows. Fix: add a depth counter and a WHERE depth < N condition, or track visited paths with an array.
Wrong result: missing rows
Check your join condition in the recursive member. Common mistake: joining on e.id = oc.manager_id instead of e.manager_id = oc.id — you want children of the current set, not managers.
Performance degradation on deep trees
PostgreSQL runs each iteration as a separate step, so depth 1000 can be slow. Add a depth limit and consider UNION (dedup) if you have duplicates. Index the foreign key (manager_id) to speed up joins.
Recursion terminated due to "MaxRecursionDepth"
PostgreSQL has a default recursion limit of 1000. You can raise it with SET max_recursive_iterations = 2000; (configurable per session) but be careful — it's usually a sign your query needs a guard.
UNION ALL with cycles produces duplicate rows
If your tree has cycles, UNION ALL will add repeating rows. Use a path array and NOT e.id = ANY(path) to prune.
What you learned & what's next
You now understand the core idea behind running recursive queries with CTEs: a CTE that references itself to walk hierarchical data. You can explain the two-part structure (anchor + recursive member), complete a practical exercise listing an org chart, add depth and path tracking, and compare recursive CTEs with alternative modeling approaches. You also know how to guard against infinite loops and debug common errors.
The next lesson in the track builds on this skill — you'll learn how to use recursive CTEs to build hierarchical aggregation queries, like summing sales per region down a tree. That's a natural extension of what you just practiced.
Pro tip: Practice writing the
WITH RECURSIVEpattern from memory — it's a common interview question and a daily tool for backend engineers.
Now run the examples above, then try modifying the query to start from a specific employee and list only their sub-tree. That's your mini exercise to lock in the concept.
Practice recap
Run the org-chart example above, then modify the starting point to a specific employee (e.g., Bob) and list only his sub-tree with depths. Next, try building a path column and experiment with a WHERE depth < 2 to see how the result set shrinks. This will cement the anchor/recursive pattern.
Common mistakes
- Putting a reference to the CTE in the anchor member — PostgreSQL errors out. Only the recursive member (after UNION ALL) can reference the CTE name.
- Forgetting the depth guard or cycle detection, causing infinite loops on cyclic data. Always add
WHERE depth < Nor a path array check. - Using
UNIONinstead ofUNION ALLunnecessarily, which slows down the query with deduplication. UseUNION ALLunless duplicates are a real issue. - Writing the recursive join the wrong way (e.g.,
e.id = oc.manager_idinstead ofe.manager_id = oc.id) and silently missing child rows.
Variations
- Use
UNIONinstead ofUNION ALLto deduplicate rows across iterations (slower but handles duplicate rows). - Use multiple anchor members (two SELECTs before UNION ALL) to start recursion from several root nodes at once.
- Leverage a recursive CTE inside an
INSERT ... SELECTto build a denormalized tree table in one go.
Real-world use cases
- Build an org-chart report that lists every employee under a manager with depth levels for HR dashboards.
- Traverse a bill-of-materials tree to compute the total quantity of components needed for a product.
- Flatten a comment-reply thread (Reddit-style) into a single ordered list for rendering without app-side recursion.
Key takeaways
- A recursive CTE uses
WITH RECURSIVEand has an anchor member (base rows) plus a recursive member that references the CTE itself. - Each iteration adds rows; recursion stops when the recursive member returns zero rows or a guard condition is met.
- Add a
depthcolumn to track levels, and a path array to prevent cycles in cyclic data. - Index foreign keys used in the recursive join to keep deep traversals fast.
- Recursive CTEs are ideal for ad-hoc hierarchy queries but may be outperformed by nested sets or materialized paths for high-frequency reads.
- Always include a depth limit or cycle guard in production to avoid infinite loops.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.