A recursive CTE walks a tree one branch at a time. It starts with known root rows, then repeatedly finds children until no new row remains.
Overview
WITH RECURSIVE contains an anchor query and a recursive query joined with UNION ALL. The anchor provides the first rows. The recursive part reads the prior iteration and finds the next level. It is useful for organization charts, folder trees, and other parent-child paths. A bad cycle can make recursion never finish, so guard against it when source data may contain loops.
Core Syntax
WITH RECURSIVE hierarchy AS (
SELECT
employee_id,
manager_id,
employee_name,
0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.employee_id,
e.manager_id,
e.employee_name,
h.depth + 1 AS depth
FROM employees AS e
JOIN hierarchy AS h
ON e.manager_id = h.employee_id
)
SELECT
employee_name,
depth
FROM hierarchy;
Key Rules & Engine Behaviors
- Lifecycle Phase: Each anchor and recursive member follows its own query lifecycle. The accumulated CTE becomes a source for the outer FROM at Step 1; its SELECT aliases are not visible to WHERE in their same query block.
- NULL & Edge Behavior: Roots usually have manager_id IS NULL because equality with NULL does not match. An orphan with a non-NULL manager that is absent from the table is not reached from a root.
- Performance & Indexing: Index the child table's manager_id for each recursive lookup. Deep or broad trees create many work rows; include only columns the walk needs.
Example 1: Walking an Organization Tree
Input (employees table):
| employee_id |
employee_name |
manager_id |
| 1901 |
Maya Lin |
NULL |
| 1902 |
Liam Chen |
1901 |
| 1903 |
Sophia Patel |
1901 |
| 1904 |
Elena Brooks |
1902 |
Query:
WITH RECURSIVE hierarchy AS (
SELECT
employee_id,
employee_name,
manager_id,
0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.employee_id,
e.employee_name,
e.manager_id,
h.depth + 1 AS depth
FROM employees AS e
JOIN hierarchy AS h
ON e.manager_id = h.employee_id
)
SELECT
employee_name,
depth
FROM hierarchy
ORDER BY depth ASC, employee_name ASC;
Output:
| employee_name |
depth |
| Maya Lin |
0 |
| Liam Chen |
1 |
| Sophia Patel |
1 |
| Elena Brooks |
2 |
Explanation:
The anchor returns Maya Lin as the only root. The first recursive pass finds Liam Chen and Sophia Patel, whose manager is Maya Lin. The next pass finds Elena Brooks under Liam Chen. No employee reports to Elena Brooks, so the walk stops.
Example 2: Carrying a Readable Path
Input (categories table):
| category_id |
category_name |
parent_id |
| 1911 |
Electronics |
NULL |
| 1912 |
Audio |
1911 |
| 1913 |
Headphones |
1912 |
| 1914 |
Furniture |
NULL |
Query:
WITH RECURSIVE category_tree AS (
SELECT
category_id,
category_name,
parent_id,
category_name::TEXT AS category_path
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT
c.category_id,
c.category_name,
c.parent_id,
t.category_path || ' > ' || c.category_name AS category_path
FROM categories AS c
JOIN category_tree AS t
ON c.parent_id = t.category_id
)
SELECT
category_name,
category_path
FROM category_tree
ORDER BY category_path ASC;
Output:
| category_name |
category_path |
| Audio |
Electronics > Audio |
| Headphones |
Electronics > Audio > Headphones |
| Electronics |
Electronics |
| Furniture |
Furniture |
Explanation:
The anchor creates a path for each root. Each recursive row appends the child name to its parent's path. The TEXT cast makes both UNION ALL branches return compatible path types. The outer sort orders text paths, not hierarchy traversal order.
Tips & Tricks
- Syntactic Sugar / Shorthand: Carry a depth integer to indent, limit, or inspect a tree.
- Defensive SQL Pattern: Keep an array of visited IDs and reject a child already in the path when untrusted data can contain cycles.
- Mental-Check Debugging Tactic: Run the anchor alone, then check one recursive pass by joining children to the anchor's IDs.
Common Pitfalls & Anti-Patterns
- UNION Instead of UNION ALL by Habit: UNION adds duplicate removal work and can hide repeated paths. Use it only when duplicate removal is the intended safety rule.
- Ignoring Cycles: A row that eventually points back to an ancestor can recurse forever or until resource limits stop it.
Key Takeaways
- Two Parts: Recursive CTEs need an anchor and a recursive member.
- Parent Index: Recursion repeatedly looks up children by parent key.
- Cycle Safety: Hierarchical data needs a loop policy when source data is not guaranteed clean.
Pro Tip: Keep a depth ceiling during exploratory work, such as WHERE h.depth < 20 in the recursive member. Remove or adjust it only after verifying the data's maximum real depth.