SQL Recursive CTE Queries for Hierarchies

Type: Software Reference Confidence: 0.95 Sources: 7 Verified: 2026-02-23 Freshness: 2026-02-23

TL;DR

Constraints

Quick Reference

ScenarioPatternTimeSpaceTrade-off
Simple parent-child tree (org chart)Recursive CTE + adjacency listO(n*d)O(n)Easy writes, recursive reads
BOM with quantity rollupRecursive CTE + SUM in outer queryO(n*d)O(n)Aggregate outside CTE only (MySQL)
Find all ancestors (bottom-up)Recursive CTE anchored at leafO(d)O(d)Reverse direction anchor
Depth-limited subtreeRecursive CTE + WHERE depth < NO(n) boundedO(n)Prevents runaway on bad data
Full path string (breadcrumb)Recursive CTE + string concatO(n*d)O(n * path_len)Path grows with depth
Cycle-safe graph traversalCYCLE clause (PG 14+) or path arrayO(n*d)O(n*d)Prevents infinite loops
Tree ordering (depth-first)SEARCH DEPTH FIRST (PG 14+) or path sortO(n*d + n log n)O(n*d)Correct visual tree ordering
Tree ordering (breadth-first)Depth counter + ORDER BY depthO(n*d + n log n)O(n)Level-by-level processing
Read-heavy flat hierarchy (<6 levels)Materialized path + LIKE queriesO(n) or O(log n)O(n * path_len)Fast reads, costly moves
Read-heavy deep hierarchyNested set model (lft/rgt)O(1) subtree, O(n) rebuildO(n)Fastest reads, expensive writes
Balanced read-writeClosure tableO(d) insert, O(1) subtreeO(n*d) rowsExtra table, best flexibility
Very large trees (>1M nodes)Recursive CTE + keyset paginationO(page * d)O(page_size)Avoids loading entire tree

Decision Tree

START
|-- Which storage model is already in place?
|   |-- ADJACENCY LIST (parent_id column) --> Use recursive CTE (this unit)
|   |-- NESTED SET (lft/rgt columns) --> Use range queries, no recursion needed
|   |-- MATERIALIZED PATH (/1/3/7/) --> Use LIKE or ltree, no recursion needed
|   |-- CLOSURE TABLE --> Use direct joins, no recursion needed
|   +-- NO TABLE YET --> continue below
|
|-- Read/write ratio?
|   |-- READ-HEAVY (>90% reads, rare moves)
|   |   |-- Depth < 10 levels? --> Materialized path + index
|   |   +-- Depth >= 10 levels? --> Nested set or closure table
|   |-- WRITE-HEAVY (frequent inserts/moves)
|   |   +-- Adjacency list + recursive CTE (this unit)
|   +-- BALANCED
|       +-- Closure table (best flexibility)
|
|-- Database engine?
|   |-- PostgreSQL 14+ --> Use SEARCH/CYCLE clauses
|   |-- PostgreSQL <14 --> Manual path array + is_cycle boolean
|   |-- MySQL 8.0+ --> WITH RECURSIVE; aggregate outside CTE
|   |-- SQL Server --> WITH (no RECURSIVE keyword); OPTION(MAXRECURSION N)
|   +-- SQLite 3.8.3+ --> WITH RECURSIVE; no built-in cycle detection
|
+-- DEFAULT --> Adjacency list + recursive CTE

Step-by-Step Guide

1. Create the adjacency-list table

Design the table with a self-referencing foreign key. Root nodes have NULL in the parent column. [src1]

CREATE TABLE employees (
    id         INT PRIMARY KEY,
    name       VARCHAR(100) NOT NULL,
    title      VARCHAR(100),
    manager_id INT REFERENCES employees(id),
    INDEX idx_manager (manager_id)
);

Verify: SELECT * FROM employees WHERE manager_id IS NULL; -- returns root node(s)

2. Write the anchor member

The anchor selects starting point(s) -- typically root nodes or a specific subtree root. [src1]

SELECT id, name, title, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL

Verify: Returns exactly the expected root node(s)

3. Write the recursive member

Join the base table to the CTE to find children of already-discovered nodes. Always increment the depth counter. [src1] [src3]

SELECT e.id, e.name, e.title, e.manager_id, cte.depth + 1
FROM employees e
INNER JOIN org_tree cte ON e.manager_id = cte.id
WHERE cte.depth < 20

Verify: Each iteration returns one level deeper; eventually returns 0 rows

4. Combine into a complete recursive CTE

Wrap anchor and recursive member with WITH RECURSIVE and UNION ALL. [src1]

WITH RECURSIVE org_tree AS (
    SELECT id, name, title, manager_id, 0 AS depth
    FROM employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.id, e.name, e.title, e.manager_id, ot.depth + 1
    FROM employees e
    INNER JOIN org_tree ot ON e.manager_id = ot.id
    WHERE ot.depth < 20
)
SELECT * FROM org_tree ORDER BY depth, name;

Verify: SELECT COUNT(*) FROM org_tree; -- matches total employee count

5. Add path tracking for breadcrumbs

Build a full path string. Syntax varies by engine. [src1]

WITH RECURSIVE org_tree AS (
    SELECT id, name, manager_id, 0 AS depth,
           ARRAY[name] AS path
    FROM employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.id, e.name, e.manager_id, ot.depth + 1,
           ot.path || e.name
    FROM employees e
    INNER JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT *, array_to_string(path, ' > ') AS breadcrumb
FROM org_tree ORDER BY path;

Verify: Breadcrumb shows CEO > VP Sales > Regional Manager > Rep

6. Add cycle detection for graphs

Use PostgreSQL 14+ CYCLE clause or manual path-based detection. [src1]

WITH RECURSIVE graph_walk AS (
    SELECT id, parent_id, name, 0 AS depth
    FROM nodes WHERE id = 1
    UNION ALL
    SELECT n.id, n.parent_id, n.name, gw.depth + 1
    FROM nodes n
    INNER JOIN graph_walk gw ON n.parent_id = gw.id
) CYCLE id SET is_cycle USING path
SELECT * FROM graph_walk WHERE NOT is_cycle;

Verify: SELECT COUNT(*) FROM graph_walk WHERE is_cycle; -- shows detected cycles

Code Examples

PostgreSQL: Org Chart with Depth-First Ordering

-- Input:  employees table with (id, name, title, manager_id)
-- Output: full org tree with depth, path, and visual indentation
WITH RECURSIVE org_tree AS (
    SELECT id, name, title, manager_id, 0 AS depth,
           ARRAY[id] AS path, name::text AS breadcrumb
    FROM employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.id, e.name, e.title, e.manager_id, ot.depth + 1,
           ot.path || e.id, ot.breadcrumb || ' > ' || e.name
    FROM employees e
    INNER JOIN org_tree ot ON e.manager_id = ot.id
    WHERE ot.depth < 50
)
SELECT id, repeat('  ', depth) || name AS indented_name,
       title, depth, breadcrumb
FROM org_tree ORDER BY path;

MySQL 8.0+: Bill of Materials with Quantity Rollup

-- Input:  bom table with (assembly_id, component_id, quantity)
-- Output: all components for assembly 1 with total quantities
WITH RECURSIVE bom_tree AS (
    SELECT component_id, assembly_id, quantity, 1 AS depth,
           CAST(CONCAT('/', assembly_id, '/', component_id)
                AS CHAR(1000)) AS path
    FROM bom WHERE assembly_id = 1
    UNION ALL
    SELECT b.component_id, b.assembly_id,
           b.quantity * bt.quantity, bt.depth + 1,
           CONCAT(bt.path, '/', b.component_id)
    FROM bom b
    INNER JOIN bom_tree bt ON b.assembly_id = bt.component_id
    WHERE bt.depth < 20
)
SELECT component_id, SUM(quantity) AS total_needed,
       MAX(depth) AS max_depth
FROM bom_tree GROUP BY component_id
ORDER BY total_needed DESC;

SQL Server: Category Tree with MAXRECURSION

-- Input:  categories table with (id, name, parent_id)
-- Output: full category tree with level and path
WITH category_tree AS (
    SELECT id, name, parent_id, 0 AS level,
           CAST(name AS NVARCHAR(MAX)) AS full_path
    FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.name, c.parent_id, ct.level + 1,
           CAST(ct.full_path + N' > ' + c.name AS NVARCHAR(MAX))
    FROM categories c
    INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, name, level, full_path
FROM category_tree
ORDER BY full_path
OPTION (MAXRECURSION 200);

Anti-Patterns

Wrong: No depth limit on recursive CTE

-- BAD -- infinite recursion if data has cycles
WITH RECURSIVE tree AS (
    SELECT id, parent_id FROM nodes WHERE id = 1
    UNION ALL
    SELECT n.id, n.parent_id
    FROM nodes n JOIN tree t ON n.parent_id = t.id
)
SELECT * FROM tree;

Correct: Always include a depth safety cap

-- GOOD -- depth limit prevents runaway recursion
WITH RECURSIVE tree AS (
    SELECT id, parent_id, 0 AS depth FROM nodes WHERE id = 1
    UNION ALL
    SELECT n.id, n.parent_id, t.depth + 1
    FROM nodes n JOIN tree t ON n.parent_id = t.id
    WHERE t.depth < 100
)
SELECT * FROM tree;

Wrong: Aggregate inside MySQL recursive member

-- BAD -- MySQL error: aggregate in recursive member
WITH RECURSIVE rollup AS (
    SELECT id, parent_id, cost FROM items WHERE id = 1
    UNION ALL
    SELECT i.id, i.parent_id, SUM(i.cost)
    FROM items i JOIN rollup r ON i.parent_id = r.id
    GROUP BY i.id
)
SELECT * FROM rollup;

Correct: Aggregate in the outer query

-- GOOD -- aggregate after recursion completes
WITH RECURSIVE rollup AS (
    SELECT id, parent_id, cost, 0 AS depth
    FROM items WHERE id = 1
    UNION ALL
    SELECT i.id, i.parent_id, i.cost, r.depth + 1
    FROM items i JOIN rollup r ON i.parent_id = r.id
    WHERE r.depth < 50
)
SELECT parent_id, SUM(cost) AS total_cost
FROM rollup GROUP BY parent_id;

Wrong: UNION instead of UNION ALL (unnecessary dedup)

-- BAD -- UNION forces expensive sort+dedup every iteration
WITH RECURSIVE tree AS (
    SELECT id, parent_id FROM nodes WHERE id = 1
    UNION
    SELECT n.id, n.parent_id
    FROM nodes n JOIN tree t ON n.parent_id = t.id
)
SELECT * FROM tree;

Correct: UNION ALL for trees with unique keys

-- GOOD -- UNION ALL avoids unnecessary sort
WITH RECURSIVE tree AS (
    SELECT id, parent_id, 0 AS depth FROM nodes WHERE id = 1
    UNION ALL
    SELECT n.id, n.parent_id, t.depth + 1
    FROM nodes n JOIN tree t ON n.parent_id = t.id
    WHERE t.depth < 100
)
SELECT * FROM tree;

Wrong: Projecting parent column instead of child

-- BAD -- projects t.id (parent) instead of n.id (child) = infinite loop
WITH RECURSIVE tree AS (
    SELECT id FROM nodes WHERE id = 1
    UNION ALL
    SELECT t.id
    FROM nodes n JOIN tree t ON n.parent_id = t.id
)
SELECT * FROM tree;

Correct: Always project the child row's columns

-- GOOD -- projects n.id (the discovered child node)
WITH RECURSIVE tree AS (
    SELECT id, 0 AS depth FROM nodes WHERE id = 1
    UNION ALL
    SELECT n.id, t.depth + 1
    FROM nodes n JOIN tree t ON n.parent_id = t.id
    WHERE t.depth < 100
)
SELECT * FROM tree;

Common Pitfalls

Version History & Compatibility

EngineVersionRecursive CTE SupportKey Limitation
PostgreSQL8.4+ (2009)Full; SEARCH/CYCLE in 14 (2021)None significant
MySQL8.0+ (2018)FullNo aggregates/window in recursive member; depth limit 1000
SQL Server2005+FullMAXRECURSION default 100; no SEARCH/CYCLE
SQLite3.8.3+ (2014)FullNo built-in cycle detection
Oracle11g R2+ (2009)Full (or CONNECT BY)CONNECT BY is legacy
BigQuery2023+FullRequires UNION ALL

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Adjacency list is already the data modelHierarchy has >1M nodes, only need subtree readsNested set or materialized path
Writes are frequent (inserts, moves, deletes)Need O(1) "is A ancestor of B?" checksNested set model (lft/rgt range check)
Hierarchy depth is unknown or variableDB doesn't support recursive CTEs (MySQL <8.0)Application-level recursion
Need aggregate over subtrees (BOM cost rollup)Simple 2-3 level hierarchy with fixed depthMultiple self-joins
Data may contain cycles needing detectionRead latency is critical, hierarchy rarely changesClosure table
Building breadcrumb paths or full-path stringsOnly need direct children (one level)Simple WHERE parent_id = ?

Important Caveats

Related Units