Traverse Hierarchical Data with Recursive CTEs
Query tree-like or graph-like structures in SQL using recursive common table expressions to walk parent-child relationships.
Overview
Relational databases are great at tables, but many real-world problems are trees: org charts, comment threads, bill-of-materials, and file systems. Recursive common table expressions let SQL walk these hierarchies by starting at the root and repeatedly joining children until no more rows are found. The result is a flat table with a depth column that shows how far each node is from the starting point.
When to Use
- For alternatives, see SQL CTEs — Common Table Expressions Explained.
Use this resource when:
- You need to query parent-child relationships stored in the same table.
- You want to list all descendants or ancestors of a node.
- A nested-set or closure-table model is too complex for your current schema.
- You are building org charts, threaded comments, or category trees.
Solution
Recursive CTE for an org chart
WITH RECURSIVE org_tree AS (
-- Anchor: start at the CEO
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: add direct reports
SELECT e.id, e.name, e.manager_id, ot.depth + 1
FROM employees e
INNER JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT * FROM org_tree
ORDER BY depth, name;
Build a path string with breadcrumbs
WITH RECURSIVE category_tree AS (
-- Anchor: root categories
SELECT id, name, parent_id, 0 AS depth, name::TEXT AS path
FROM categories
WHERE parent_id IS NULL
UNION ALL
-- Recursive: append child name to path
SELECT c.id, c.name, c.parent_id, ct.depth + 1,
ct.path || ' > ' || c.name
FROM categories c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, name, depth, path
FROM category_tree
ORDER BY path;
Find all descendants of a specific node
WITH RECURSIVE descendants AS (
-- Anchor: start from a specific node
SELECT id, name, parent_id, 0 AS depth
FROM categories
WHERE id = 42 -- starting node
UNION ALL
-- Recursive: find children
SELECT c.id, c.name, c.parent_id, d.depth + 1
FROM categories c
INNER JOIN descendants d ON c.parent_id = d.id
)
SELECT * FROM descendants
ORDER BY depth, name;
Find all ancestors of a node (path to root)
WITH RECURSIVE ancestors AS (
-- Anchor: start from a specific node
SELECT id, name, parent_id, 0 AS depth
FROM categories
WHERE id = 99 -- starting node
UNION ALL
-- Recursive: find parent
SELECT c.id, c.name, c.parent_id, a.depth + 1
FROM categories c
INNER JOIN ancestors a ON a.parent_id = c.id
)
SELECT * FROM ancestors
ORDER BY depth DESC;
Cycle detection with a path array
WITH RECURSIVE safe_tree AS (
-- Anchor
SELECT id, name, manager_id, 0 AS depth,
ARRAY[id] AS visited
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: only add nodes not already visited
SELECT e.id, e.name, e.manager_id, st.depth + 1,
st.visited || e.id
FROM employees e
INNER JOIN safe_tree st ON e.manager_id = st.id
WHERE NOT e.id = ANY(st.visited)
)
SELECT id, name, depth, visited
FROM safe_tree
ORDER BY depth, name;
Bill of materials explosion
WITH RECURSIVE bom_explosion AS (
-- Anchor: top-level assembly
SELECT
part_id, part_name, 1 AS quantity,
0 AS depth, ARRAY[part_id] AS path
FROM parts
WHERE parent_part_id IS NULL
AND part_id = 'BICYCLE-001'
UNION ALL
-- Recursive: explode sub-components
SELECT
p.part_id, p.part_name,
p.quantity * be.quantity AS total_quantity,
be.depth + 1,
be.path || p.part_id
FROM parts p
INNER JOIN bom_explosion be ON p.parent_part_id = be.part_id
)
SELECT
part_id, part_name, total_quantity, depth,
array_to_string(path, ' -> ') AS assembly_path
FROM bom_explosion
ORDER BY depth, part_name;
Limit recursion depth
WITH RECURSIVE limited_tree AS (
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, lt.depth + 1
FROM employees e
INNER JOIN limited_tree lt ON e.manager_id = lt.id
WHERE lt.depth < 5 -- stop at 5 levels
)
SELECT * FROM limited_tree
ORDER BY depth, name;
Explanation
The recursive CTE has two parts. The anchor member selects the top-level nodes, typically where the parent reference is NULL. The recursive member joins the employees table to the CTE itself, finding rows whose manager_id matches an id already in the result set. UNION ALL combines both parts, and the database repeats the recursive step until no new rows are produced. The depth column increments each level so you can visualize the hierarchy or limit recursion with WHERE depth < N.
How the database executes recursive CTEs
- Evaluate the anchor member and store results in a working table
- Evaluate the recursive member using the working table as input
- Replace the working table with the new results
- Repeat steps 2-3 until the working table is empty
- Combine all results with
UNION ALL
Cycle safety
Without cycle detection, a row that references itself (directly or transitively) causes infinite recursion. The visited array approach tracks which nodes have been seen. PostgreSQL 14+ also supports the CYCLE clause:
WITH RECURSIVE org_tree AS (
SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)
CYCLE id SET is_cycle TO true DEFAULT false USING cycle_path
SELECT * FROM org_tree WHERE NOT is_cycle;
Variants
| Use case | Anchor | Recursive join |
|---|---|---|
| Org chart (top-down) | manager_id IS NULL | manager_id = id |
| Path to root (bottom-up) | id = ? | id = parent_id |
| Comment thread | parent_id IS NULL | parent_id = id |
| Bill of materials | parent_part_id IS NULL | parent_part_id = id |
| File system | parent_dir_id IS NULL | parent_dir_id = id |
| Category tree | parent_id IS NULL | parent_id = id |
What Works
- Always include a cycle guard. Add a
patharray or avisitedcheck to prevent infinite recursion when data contains cycles. - Index the parent/child columns. An index on
manager_idmakes the recursive join much faster. - Limit recursion depth when possible. Use
WHERE depth < 10to avoid runaway queries on bad data. - Materialize small trees if read often. A recursive CTE on every request can be expensive; cache or precompute for static hierarchies.
- Prefer adjacency lists for simple trees. Recursive CTEs work best with simple parent-child columns.
- Use
ARRAYfor path tracking. Arrays are efficient for cycle detection and can be rendered as breadcrumbs. - Test with small datasets first. Recursive CTEs can be hard to debug; start with 10-20 rows and verify depth and path columns.
Common Mistakes
- Forgetting
UNION ALLvsUNION. Recursive CTEs requireUNION ALLbecause duplicates are intentional.UNIONremoves duplicates and can hide data. - No cycle protection. A row pointing to itself causes a stack overflow or query cancellation.
- Missing anchor condition. Without a clear starting point, the CTE returns nothing or everything.
- Recursive step joining the wrong direction. Confusing
parent_id = idandid = parent_idproduces ancestors instead of descendants. - Running recursive CTEs on huge graphs. Deep recursion can exhaust work memory or hit database limits.
- Not indexing the join column. The recursive member joins on
manager_id; without an index, each iteration scans the full table. - Using depth limit without understanding data. A
WHERE depth < 5may silently truncate legitimate deep hierarchies.
Troubleshooting
- Query is slow after an index change: check execution plans and cardinality estimates. Rebuild statistics and verify the index is being used.
- Replication lag grows: monitor network, disk I/O, and long transactions. Split large writes and consider parallel replication.
- Connections exhausted: review connection pool size, idle timeouts, and leaked connections.
- Backup takes too long: enable compression, incremental backups, and off-peak scheduling.
- Deadlocks in high concurrency: access tables and rows in a consistent order.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply traverse hierarchical data with recursive ctes when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Performance Tips
- Index the join column. The recursive member joins on
manager_idorparent_id. Without an index, each iteration scans the full table.
CREATE INDEX idx_employees_manager_id ON employees (manager_id);
CREATE INDEX idx_categories_parent_id ON categories (parent_id);
- Use
MATERIALIZEDfor large CTEs. In PostgreSQL 12+, CTEs are inlined by default. For recursive CTEs that are referenced multiple times, useMATERIALIZEDto compute once:
WITH RECURSIVE org_tree AS MATERIALIZED (
SELECT id, name, manager_id, 0 AS depth FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, ot.depth + 1 FROM employees e JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT * FROM org_tree;
-
Set
work_memhigher for deep trees. Recursive CTEs build up intermediate results in memory. Increasework_memfor the session if you hit disk spills. -
Use
EXPLAIN ANALYZEto verify iteration count. The plan shows how many iterations the recursive member ran. If it runs hundreds of times, check for missing indexes or cycles.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
Can recursive CTEs handle cycles?
Yes, but you must track visited nodes with an array or use PostgreSQL's CYCLE clause (14+). Without protection, cycles cause infinite recursion.
Are recursive CTEs supported in all databases?
Most modern databases support them: MySQL 8.0+, PostgreSQL, SQL Server, SQLite 3.8.3+. Oracle uses CONNECT BY as an alternative.
How do I build a path string in a recursive CTE?
Add a column like path || '/' || name and pass it through each recursion level to show the full breadcrumb.
What is the performance impact of recursive CTEs?
Each iteration of the recursive member runs a join. With an index on the join column, each iteration is an index lookup. Without an index, each iteration scans the full table, making the CTE O(n * depth).
Can I use recursive CTEs for graph traversal?
Yes, but only for trees (each node has one parent). For general graphs with multiple paths to the same node, use cycle detection and consider specialized graph databases.
How do I limit the number of rows returned?
Use LIMIT N in the outer query. The database still computes all recursive results, but only returns N rows. For true early termination, use FETCH FIRST N ROWS ONLY in the recursive member.
Can I aggregate data at each level of the hierarchy?
Yes. Use a recursive CTE to generate the tree, then join back to aggregate. For example, sum all sales for each manager including their sub-managers' sales.
How do I debug a recursive CTE that returns no rows?
Run the anchor member alone first. If it returns rows, add the recursive member with LIMIT 1 iteration. Check the join condition direction and verify the anchor condition matches your data.
Related Resources
SQL CTEs — Common Table Expressions Explained
A practical guide to SQL Common Table Expressions (CTEs): non-recursive and recursive CTEs, readability, performance, and when to use them over subqueries.
RecipeFind and Remove Duplicate Rows in SQL
Detect duplicate records in SQL tables using GROUP BY and HAVING, then remove them safely while keeping the canonical row.
DocDatabase Schema Documentation Template
A template for documenting database schemas with entity relationships, field definitions, and migration history.
GuideFull-Text Search — Implement Search That Actually Works
A practical guide to full-text search: PostgreSQL tsvector, Elasticsearch indexing, query design, relevance tuning, and building search that users trust with autocomplete, faceting, and typo tolerance.
GuideRead Replicas: Scale Reads Without Changing Application
A practical guide to read replicas: setting up replication, routing read queries, handling replication lag, and scaling read-heavy workloads with PostgreSQL, MySQL, and cloud-managed replicas.
RecipeAnalyze and Optimize SQL Indexes with EXPLAIN
Identify missing, unused, and inefficient indexes by reading execution plans and measuring query cost with EXPLAIN.