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.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
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
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;
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.
Variants
| Use case | Anchor | Recursive join |
|---|---|---|
| Org chart | manager_id IS NULL | manager_id = id |
| Comment thread | parent_id IS NULL | parent_id = id |
| Bill of materials | parent_part_id IS NULL | parent_part_id = id |
| Path to root | id = ? | id = parent_id |
Best Practices
- 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.
Common Mistakes
- Forgetting
UNION ALLvsUNION. Recursive CTEs requireUNION ALLbecause duplicates are intentional. - 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.
Frequently Asked Questions
Q: Can recursive CTEs handle cycles?
A: Yes, but you must track visited nodes. PostgreSQL can also use CYCLE detection syntax in newer versions.
Q: Are recursive CTEs supported in all databases?
A: Most modern databases support them, but MySQL 8.0+, PostgreSQL, SQL Server, and SQLite 3.8.3+ do. Oracle uses CONNECT BY as an alternative.
Q: How do I build a path string in a recursive CTE?
A: Add a column like path || '/' || name and pass it through each recursion level to show the full breadcrumb.
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 Logic
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.