Recursive CTE: a self-referencing query with anchor (seed) + recursive member (iterates until empty)
Engine uses a worktable model: recursive member sees only the latest batch, not the full result
Cycle detection: use visited-ID arrays (cross-engine) or PostgreSQL 14+ CYCLE clause
Performance: an index on parent_id is non-negotiable — full scan per iteration destroys throughput
Biggest mistake: filtering in the outer SELECT instead of inside the recursive member — materialisation happens first
Plain-English First
Imagine you're looking for your great-great-grandmother in a family tree. You start with yourself, then find your parents, then their parents, and keep going up until there's no one left. A recursive SQL query does exactly that — it starts with one row of data, uses that result to find more rows, then uses THOSE results to find even more, repeating itself until it hits a dead end. It's like following a trail of breadcrumbs where each crumb tells you where the next one is.
Every production database hides a secret: a huge chunk of its most important data is inherently hierarchical. Org charts, product categories, bill-of-materials assemblies, comment threads, file systems, permission inheritance chains — they all share the same shape. A node points to a parent, which points to another parent, which eventually points to nothing. Flattening that structure into a two-dimensional SQL result set was, for decades, genuinely painful. Recursive SQL queries — specifically recursive Common Table Expressions — exist precisely to solve that pain elegantly inside the database engine, without shipping raw data up to application code and looping in Python or Java.
The classical alternative was either a 'closure table' (storing every ancestor-descendant pair explicitly) or a 'nested sets' model (numbering left and right boundaries). Both work, but they push write-time complexity sky-high. With recursive CTEs, you store the simplest possible relationship — just parent_id — and let the query engine do the traversal at read time. The tradeoff is query complexity and, if you're not careful, runaway execution that locks up your database.
By the end of this article you'll understand exactly how the SQL engine executes a recursive CTE step by step, you'll be able to write recursive queries for tree traversal, path building, and cycle detection, and you'll know the three performance traps that catch senior engineers off guard in production.
The Anatomy of a Recursive CTE — What the Engine Actually Does
A recursive CTE has two mandatory parts separated by UNION ALL: the anchor member and the recursive member. The anchor member is a plain SELECT that returns the starting row(s) — your 'seed'. The recursive member is a SELECT that JOINs back to the CTE itself, producing the next level of results based on what the previous iteration found.
The engine doesn't actually call itself like a programming language function does. It uses an iterative worktable approach. First it runs the anchor and stores those rows in a temporary worktable. Then it runs the recursive member against ONLY the rows currently in the worktable, producing a new batch. That new batch replaces the worktable content. This repeats until the recursive member returns zero rows or you hit the MAXRECURSION limit.
This is critical to understand: the recursive member never sees the full accumulated result — only the most recently added batch. That's why it's called a 'working table' model, not true recursion. It's breadth-first by default in most engines (SQL Server, PostgreSQL). You can coerce depth-first ordering using a path column trick, which we'll cover shortly.
SQL Server defaults to MAXRECURSION 100. PostgreSQL has no hard cap but you can set one. MySQL added recursive CTE support in version 8.0. Always verify your engine version before betting your data pipeline on this feature.
employee_hierarchy_traversal.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
-- ============================================================-- SETUP: A realistic employee table with a self-referencing-- parent (manager) relationship.-- Tested on PostgreSQL 15, SQL Server 2019, MySQL 8.0+-- ============================================================CREATETABLEemployee (
employee_id INTPRIMARYKEY,
full_name VARCHAR(100) NOTNULL,
job_title VARCHAR(100) NOTNULL,
manager_id INTNULL, -- NULL means this person is the root (CEO)FOREIGNKEY (manager_id) REFERENCESemployee(employee_id)
);
INSERTINTOemployee (employee_id, full_name, job_title, manager_id) VALUES
(1, 'Sandra Okafor', 'CEO', NULL),
(2, 'Marcus Webb', 'VP of Engineering', 1),
(3, 'Priya Nair', 'VP of Marketing', 1),
(4, 'Leo Hartmann', 'Senior Engineer', 2),
(5, 'Yuki Tanaka', 'Senior Engineer', 2),
(6, 'Carla Mendez', 'Marketing Manager', 3),
(7, 'James Osei', 'Junior Engineer', 4),
(8, 'Fatima Al-Amin', 'Junior Engineer', 4),
(9, 'Ravi Patel', 'Marketing Analyst', 6);
-- ============================================================-- THE RECURSIVE CTE-- Goal: Starting from Sandra (the CEO), walk down the entire-- reporting chain and show each person's depth level and their-- full reporting path as a readable string.-- ============================================================WITHRECURSIVE org_hierarchy AS (
-- ---- ANCHOR MEMBER ------ This runs exactly ONCE. It seeds the worktable with just-- the root node (the CEO who has no manager).SELECT
employee_id,
full_name,
job_title,
manager_id,
0AS depth_level, -- CEO is at depth 0
full_name::TEXTAS reporting_path -- Path starts with just the CEO nameFROM employee
WHERE manager_id ISNULL-- The root: no manager above themUNIONALL-- ---- RECURSIVE MEMBER ------ This runs repeatedly. Each iteration joins the CURRENT-- worktable batch (org_hierarchy) to the employee table-- to find one more level of direct reports.SELECT
e.employee_id,
e.full_name,
e.job_title,
e.manager_id,
oh.depth_level + 1, -- Go one level deeper
oh.reporting_path || ' → ' || e.full_name -- Append this person to the pathFROM employee e
INNERJOIN org_hierarchy oh
ON e.manager_id = oh.employee_id -- Match employees whose manager is in the current batch
)
-- ============================================================-- FINAL SELECT — pull everything out of the accumulated CTE-- ============================================================SELECT
employee_id,
REPEAT(' ', depth_level) || full_name AS indented_name, -- Visual indentation by depth
job_title,
depth_level,
reporting_path
FROM org_hierarchy
ORDERBY reporting_path; -- Path ordering gives us a clean depth-first visual
The recursive member joins to the CTE name, but the engine only gives it the LATEST batch of rows — not every row accumulated so far. Think of it as a sliding window, not a growing pile. This is why building a 'path' string works: you carry state forward through each iteration by including it in your SELECT columns.
Production Insight
The worktable model means the recursive member never sees the full dataset — only the last batch.
If you accidentally reference the CTE in a non-recursive context (e.g. a subquery), the engine may materialise the entire result anyway.
Rule: never assume the engine will optimise your CTE — check EXPLAIN ANALYZE to confirm it's using a Recursive Union plan.
Key Takeaway
Recursive CTEs use a breadth-first worktable iteration, not stack-based recursion.
The recursive member only sees the latest batch — carry forward any state (depth, path) as explicit columns.
This mental model explains why depth-first ordering requires an explicit path sort.
Cycle Detection and the MAXRECURSION Safety Net — Preventing Runaway Queries
Here's the production nightmare scenario: someone creates a circular reference in your data. Employee A reports to Employee B, who reports to Employee C, who somehow reports back to Employee A. Your recursive CTE will loop forever — or until the engine cuts it off — burning CPU and memory the whole time.
SQL Server throws an error at 100 iterations by default (you can raise or disable this). PostgreSQL keeps going until it runs out of memory or you add CYCLE detection syntax (added in PostgreSQL 14). MySQL respects the cte_max_recursion_depth session variable (default 1000).
The safest universal technique, which works across ALL engines including older PostgreSQL versions, is to maintain an array of visited IDs in a path column and check for membership before recursing. If the next employee_id already appears in the visited array, you stop. This is manual cycle detection and it's what you should use in production when you can't guarantee data integrity on the parent_id column.
PostgreSQL 14+ also gives you the declarative CYCLE clause which is cleaner but less portable. We'll show both. The key mental model: think of cycle detection as leaving a trail of footprints. Before you step somewhere, you look down and check if your own footprint is already there.
cycle_detection_safe_recursion.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
-- ============================================================-- Simulating a CYCLE: Let's corrupt the data to create a loop.-- James Osei (id=7) will be made to 'manage' Leo Hartmann (id=4)-- creating a circle: Leo → James → Leo → James → ...-- ============================================================-- WARNING: Do not run this on the real table without a transaction!-- This temporarily breaks the tree for demonstration purposes.BEGIN;
UPDATE employee
SET manager_id = 7-- James now 'manages' Leo (his own senior!)WHERE employee_id = 4; -- Creating the cycle: 4 → 7 → 4 → 7 ...-- ---- APPROACH 1: Manual cycle detection using an integer ARRAY ------ Works on PostgreSQL 12+. Replace INT[] with a VARCHAR path string-- for SQL Server / MySQL compatibility.WITHRECURSIVE safe_org_traversal AS (
-- Anchor: start at CEO, initialise the visited_ids arraySELECT
employee_id,
full_name,
manager_id,
0AS depth_level,
ARRAY[employee_id] AS visited_ids, -- Track every ID we've seenFALSEAS cycle_detected -- Flag: are we in a loop?FROM employee
WHERE manager_id ISNULLUNIONALLSELECT
e.employee_id,
e.full_name,
e.manager_id,
sot.depth_level + 1,
sot.visited_ids || e.employee_id, -- Append new ID to the visited list
e.employee_id = ANY(sot.visited_ids) -- TRUE if we've seen this ID beforeFROM employee e
INNERJOIN safe_org_traversal sot
ON e.manager_id = sot.employee_id
WHERENOT sot.cycle_detected -- STOP recursing if we already flagged a cycleAND e.employee_id <> ALL(sot.visited_ids) -- Don't recurse into an already-visited node
)
SELECT
employee_id,
full_name,
depth_level,
cycle_detected,
visited_ids
FROM safe_org_traversal
ORDERBY depth_level, employee_id;
ROLLBACK; -- Undo the corrupted data-- ============================================================-- APPROACH 2: PostgreSQL 14+ declarative CYCLE clause (cleaner)-- ============================================================WITHRECURSIVE org_with_cycle_check AS (
SELECT employee_id, full_name, manager_id, 0AS depth_level
FROM employee
WHERE manager_id ISNULLUNIONALLSELECT e.employee_id, e.full_name, e.manager_id, owc.depth_level + 1FROM employee e
INNERJOIN org_with_cycle_check owc ON e.manager_id = owc.employee_id
)
CYCLE employee_id -- Tell PostgreSQL WHICH column to watch for repeatsSET is_cycle -- A boolean column PostgreSQL adds automaticallyUSING cycle_path -- An array column PostgreSQL manages for youSELECT
employee_id,
full_name,
depth_level,
is_cycle
FROM org_with_cycle_check
ORDERBY depth_level;
-- ============================================================-- SQL SERVER EQUIVALENT: Setting a custom recursion limit-- Use this when you know your maximum legitimate tree depth-- ============================================================-- OPTION (MAXRECURSION 50) <-- Add this at the very end of the query-- OPTION (MAXRECURSION 0) <-- 0 means UNLIMITED — dangerous with dirty data
Output
-- Output from APPROACH 1 (with corrupted data, before ROLLBACK)
-- Notice the cycle is caught cleanly — no infinite loop, no error
-- James's children are skipped because employee_id 4 (Leo) is already in visited_ids
-- No infinite loop. No engine crash. Clean stop.
Watch Out:
Never use OPTION (MAXRECURSION 0) on SQL Server in production unless you have cycle detection built into the query itself. It removes the engine's safety net entirely. A single bad row in your parent_id column will spin the query until the server runs out of memory or someone kills it manually. Always pair unlimited recursion with an explicit visited-IDs guard.
Production Insight
Cycle detection is not optional — assume dirty data will reach your production hierarchy.
The visited-IDs array technique works on all engines and is the most reliable pattern.
PostgreSQL 14+ CYCLE clause is cleaner but ties you to a specific version — use it only if you control the deployment target.
Key Takeaway
Circular references are a silent production killer for recursive CTEs.
The safest pattern is a visited-IDs array guard inside the recursive member.
Never rely solely on MAXRECURSION — add cycle detection to your query logic.
Real-World Patterns — Aggregating Subtrees and Finding All Ancestors
Two patterns come up constantly in production work. First: given a node, find ALL its ancestors up to the root (upward traversal). Second: given a node, find ALL its descendants and compute an aggregate across the whole subtree — like the total headcount under a manager, or the total cost of all components in a bill of materials.
Upward traversal just means you flip the JOIN. Instead of joining where employee.manager_id = cte.employee_id (going down), you join where cte.manager_id = employee.employee_id (going up). The anchor is the leaf node you're starting from.
Subtree aggregation is where recursive CTEs really shine against closure tables. You traverse the full subtree in the CTE, then wrap it in an outer query and GROUP BY the root. The key insight is that the CTE gives you a flat list of all descendants — you then aggregate that flat list however you need. No recursive summing required. The recursion only handles the traversal; the aggregation is just normal SQL on top of it.
Bill of Materials (BOM) queries are the canonical example. A product is made of components, each of which is itself made of sub-components, each potentially shared across multiple parent products. Recursive CTEs handle BOM explosions elegantly, though watch for shared components creating DAGs (Directed Acyclic Graphs) rather than pure trees — you may count the same component multiple times without careful deduplication.
subtree_aggregation_and_ancestor_lookup.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
-- ============================================================-- PATTERN 1: Find ALL ancestors of a given employee (upward)-- Use case: Show the full chain of command above 'James Osei'-- ============================================================WITHRECURSIVE ancestors_of_james AS (
-- Anchor: start AT James Osei (our leaf node)SELECT
employee_id,
full_name,
job_title,
manager_id,
0AS levels_above
FROM employee
WHERE employee_id = 7-- James Osei's IDUNIONALL-- Recursive: join UPWARD — find the manager of whoever we just foundSELECT
e.employee_id,
e.full_name,
e.job_title,
e.manager_id,
aj.levels_above + 1FROM employee e
INNERJOIN ancestors_of_james aj
ON aj.manager_id = e.employee_id -- Flip! We go UP: CTE's manager_id matches parent's employee_id
)
SELECT
levels_above,
full_name,
job_title
FROM ancestors_of_james
ORDERBY levels_above;
-- ============================================================-- PATTERN 2: Subtree aggregation — headcount under each VP-- Use case: HR dashboard showing how many people report to-- each VP (including all levels below them)-- ============================================================WITHRECURSIVE full_subtree AS (
-- Anchor: every direct report of the CEO (the VPs)-- We tag each person with the VP they ultimately roll up toSELECT
employee_id,
full_name,
manager_id,
employee_id AS subtree_root_id -- Each VP is the 'root' of their own subtreeFROM employee
WHERE manager_id = 1-- Direct reports of Sandra (CEO)UNIONALL-- Recursive: find everyone under the current batch,-- carrying forward the original subtree_root_id so we always-- know which VP this person ultimately rolls up toSELECT
e.employee_id,
e.full_name,
e.manager_id,
fs.subtree_root_id -- Preserve the root tag through all levelsFROM employee e
INNERJOIN full_subtree fs
ON e.manager_id = fs.employee_id
)
-- Now aggregate the flat list — standard GROUP BY, no recursion needed hereSELECT
root_emp.full_name AS vp_name,
root_emp.job_title,
COUNT(fs.employee_id) AS total_headcount_including_self_minus_one,
COUNT(fs.employee_id) + 1AS total_headcount_including_vp
FROM full_subtree fs
INNERJOIN employee root_emp
ON fs.subtree_root_id = root_emp.employee_id
GROUPBY
root_emp.employee_id,
root_emp.full_name,
root_emp.job_title
ORDERBY total_headcount_including_vp DESC;
Carry a 'root tag' column through your recursion instead of running a separate ancestor lookup for each row. The pattern of setting subtree_root_id in the anchor and propagating it unchanged through the recursive member is one of the most reusable tricks in recursive SQL — it collapses what would be N+1 queries into a single traversal pass.
Production Insight
Upward traversal: flip the JOIN direction but keep the WHERE filter inside the recursive member to avoid full worktable scans.
Subtree aggregation: the CTE flattens the tree, then a simple GROUP BY does the heavy lifting — no recursive sums.
BOM explosions with shared components: always deduplicate with DISTINCT before aggregating, or you'll overcount shared sub-assemblies.
Key Takeaway
Carry a root tag (subtree_root_id) through recursion to aggregate whole subtrees in one pass.
Upward vs downward traversal is just a JOIN direction swap.
Shared components in DAGs require explicit dedup — UNION ALL preserves duplicates, UNION hides them but breaks correctness.
Performance Internals and Production Tuning — Why Recursive CTEs Can Destroy Your Database
Recursive CTEs look elegant but they hide real performance danger. Understanding the execution model is non-negotiable before you ship them to production.
First: recursive CTEs are NOT inlined in PostgreSQL the way regular CTEs sometimes are. They're always materialised — the engine writes intermediate results to a worktable (typically in memory, spilling to disk if large). This means you cannot push WHERE clause predicates from the outer query INTO the CTE. If you filter on depth_level > 2 in your final SELECT, the CTE still computed every level. Always filter INSIDE the recursive member to prune the search space early.
Second: the JOIN inside the recursive member runs once per iteration. If your recursive member does a sequential scan of a 10-million-row table on each pass, and your tree is 8 levels deep, you're doing 8 full scans. An index on manager_id is not optional — it's the difference between milliseconds and minutes.
Third: avoid selecting columns you don't need inside the CTE. Every extra column gets materialised and written to the worktable on every iteration. This compounds fast for wide tables with TEXT or JSONB columns.
For very deep trees (thousands of levels, like a BOM explosion for complex machinery), consider whether a closure table or a materialised path (ltree in PostgreSQL) might be a better architectural choice. Recursive CTEs are ideal for trees up to a few hundred nodes per query invocation. Beyond that, benchmark aggressively.
recursive_cte_performance_tuning.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
-- ============================================================-- PERFORMANCE DEMONSTRATION-- Showing the difference between an untuned and a tuned-- recursive CTE on a larger dataset.-- ============================================================-- STEP 1: Make sure this index EXISTS. Without it, every-- recursive iteration does a full table scan.-- On a 500k-row employee table this index reduces query time-- from ~4s to ~30ms.CREATEINDEXIFNOTEXISTS idx_employee_manager_id
ONemployee (manager_id);
-- STEP 2: EXPLAIN ANALYZE to see the execution plan-- Always do this before shipping a recursive CTE to production.-- Look for 'CTE Scan' and 'WorkTable Scan' nodes.EXPLAIN (ANALYZE, BUFFERS, FORMATTEXT)
WITHRECURSIVE org_hierarchy AS (
SELECT employee_id, full_name, manager_id, 0AS depth_level
FROM employee
WHERE manager_id ISNULLUNIONALLSELECT e.employee_id, e.full_name, e.manager_id, oh.depth_level + 1FROM employee e
INNERJOIN org_hierarchy oh ON e.manager_id = oh.employee_id
)
SELECT * FROM org_hierarchy;
-- ============================================================-- TUNING PATTERN: Prune INSIDE the recursive member.-- If you only care about the top 2 levels, add the WHERE-- clause IN the recursive member — not just in the outer query.-- ============================================================-- WRONG (inefficient): The CTE computes ALL levels, then filtersWITHRECURSIVE all_levels AS (
SELECT employee_id, full_name, manager_id, 0AS depth_level
FROM employee WHERE manager_id ISNULLUNIONALLSELECT e.employee_id, e.full_name, e.manager_id, al.depth_level + 1FROM employee e
INNERJOIN all_levels al ON e.manager_id = al.employee_id
-- No depth guard here — recurses to the bottom of the entire tree
)
SELECT * FROM all_levels
WHERE depth_level <= 2; -- Filter applied AFTER full traversal — wasteful!-- RIGHT (efficient): Stop recursing as soon as depth limit is hitWITHRECURSIVE top_two_levels AS (
SELECT employee_id, full_name, manager_id, 0AS depth_level
FROM employee WHERE manager_id ISNULLUNIONALLSELECT e.employee_id, e.full_name, e.manager_id, t2l.depth_level + 1FROM employee e
INNERJOIN top_two_levels t2l ON e.manager_id = t2l.employee_id
WHERE t2l.depth_level < 2-- Guard INSIDE recursive member — stops traversal early
)
SELECT * FROM top_two_levels
ORDERBY depth_level, employee_id;
-- ============================================================-- PRODUCTION SAFETY: Always set MAXRECURSION (SQL Server)-- or check pg_stat_activity for runaway queries (PostgreSQL)-- ============================================================-- SQL Server: append to any recursive query in production-- OPTION (MAXRECURSION 200) -- Tune to your known maximum tree depth + buffer-- PostgreSQL: set per-session if needed-- SET work_mem = '64MB'; -- Give the worktable more memory before spilling to disk
Output
-- EXPLAIN ANALYZE output excerpt (PostgreSQL) — what to look for:
CTE Scan on org_hierarchy (cost=0.00..0.18 rows=9 width=50)
-> Recursive Union
-> Seq Scan on employee -- Anchor: full scan is fine for tiny tables
Filter: (manager_id IS NULL)
-> Hash Join
Hash Cond: (e.manager_id = oh.employee_id)
-> Seq Scan on employee -- !! On large tables this becomes:
-> WorkTable Scan on org_hierarchy -- Index Scan on employee (manager_id)
-- After adding idx_employee_manager_id, the 'Seq Scan on employee'
-- inside the recursive member becomes 'Index Scan' — that's the win.
-- 'WorkTable Scan' is normal — that's the engine reading its own temp results.
In PostgreSQL, a recursive CTE is ALWAYS materialised — unlike non-recursive CTEs which PostgreSQL 12+ can inline. This means outer-query WHERE filters are never pushed into the CTE. If you're filtering by depth, subtree root, or any other column computed inside the recursion, always put that filter INSIDE the recursive member to prune the worktable early. Failure to do this is the single most common reason recursive CTEs cause production incidents.
Production Insight
Index on parent_id: mandatory. Without it, each iteration does a full table scan.
Filter early: push WHERE clauses into the recursive member to prune worktable size.
Materialisation is always: recursive CTEs are never inlined — every column in the SELECT list is written per iteration.
Index the parent_id column — it's not optional for production.
Filter inside the recursive member, not in the outer query.
Minimise the column list in the CTE to reduce worktable write overhead.
When to Avoid Recursive CTEs — Alternative Patterns for Hierarchical Data
Recursive CTEs are not the universal answer for all tree data. They have real limits that can push you toward alternative storage patterns.
Closure table: stores every ancestor-descendant pair as a separate row. Writes become expensive (O(n) inserts per node addition), but reads are trivial — a single range query. Best for read-heavy workloads where the tree rarely changes.
Nested sets (modified preorder traversal): stores left/right integer ranges. Reads are fast (one query to get all descendants), but inserting or moving a node requires rebalancing the entire tree — a catastrophic O(n) write cost. Best for static trees that are read far more often than written.
Materialised path (ltree in PostgreSQL): stores a string path column like "1_2_4_7". Reads are fast with a prefix query, writes are cheap (just update one string), but path length grows with depth and you lose referential integrity via foreign keys.
When should you NOT use a recursive CTE? - You need to query subtrees hundreds of times per second — the per-query traversal cost adds up. A closure table will be faster. - Your tree is thousands of levels deep — recursive CTEs may hit recursion limits or memory issues. - You need to guarantee write performance for tree restructuring — recursive CTEs require no schema changes, but if you use them to compute aggregates at write time, you're paying the traversal cost every insert.
The rule: recursive CTEs are ideal for ad-hoc or moderate-frequency queries with trees up to ~500 nodes deep. For high-read, low-write patterns, use a closure table. For static trees, nested sets can out-read anything.
closure_table_example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
-- ============================================================-- CLOSURE TABLE APPROACH-- A separate table storing all ancestor-descendant pairs.-- This is an alternative to recursive CTEs for read-heavy workloads.-- ============================================================-- Tree table: just the nodes (no parent_id needed, though often kept)CREATETABLEcategory (
category_id INTPRIMARYKEY,
name VARCHAR(100) NOTNULL
);
-- Closure table: every ancestor-descendant pair + self-referenceCREATETABLEcategory_closure (
ancestor_id INTNOTNULL,
descendant_id INTNOTNULL,
depth INTNOTNULL, -- 0 means self-referencePRIMARYKEY (ancestor_id, descendant_id),
FOREIGNKEY (ancestor_id) REFERENCEScategory(category_id),
FOREIGNKEY (descendant_id) REFERENCEScategory(category_id)
);
-- Insert categoriesINSERTINTO category VALUES (1, 'Electronics'), (2, 'Laptops'), (3, 'Phones'), (4, 'Gaming Laptops');
-- Insert closure relationships (setup for new node 4: ancestor path)INSERTINTO category_closure VALUES
(1,1,0), (2,2,0), (3,3,0), (4,4,0), -- self references
(1,2,1), (1,4,2), -- Electronics -> Laptops (depth 1), Electronics -> Gaming Laptops (depth 2)
(2,4,1); -- Laptops -> Gaming Laptops (depth 1)-- Get all descendants of Electronics (depth 1: Laptops, depth 2: Gaming Laptops)SELECT c.name, cc.depth
FROM category c
JOIN category_closure cc ON c.category_id = cc.descendant_id
WHERE cc.ancestor_id = 1AND cc.depth > 0; -- exclude self-- Get all ancestors of Gaming LaptopsSELECT c.name AS ancestor, cc.depth
FROM category c
JOIN category_closure cc ON c.category_id = cc.ancestor_id
WHERE cc.descendant_id = 4AND cc.depth > 0;
-- ============================================================-- Compare: Recursive CTE query to do the same thing-- ============================================================WITHRECURSIVE cat_tree AS (
SELECT category_id, name, 0AS depth
FROM category WHERE category_id = 1UNIONALLSELECT c.category_id, c.name, ct.depth + 1FROM category c
JOIN cat_tree ct ON c.category_id = ? -- This would need a self-referencing parent_id
)
SELECT * FROM cat_tree;
-- Note: closure table avoids the recursive join at read time
Output
-- Closure table query output:
name | depth
------------------+-------
Laptops | 1
Gaming Laptops | 2
-- Ancestors of Gaming Laptops:
ancestor | depth
------------------+-------
Electronics | 2
Laptops | 1
Choosing the Right Pattern
Recursive CTE: simple schema (just parent_id), moderate read speed, fast writes — best for dynamic trees.
Closure table: complex schema, blazing reads, slow writes — best for reference data like product categories.
Nested sets: moderate schema, decent reads, catastrophic writes — only for static trees.
Production Insight
Closure table writes: every node insert requires O(d) rows where d = tree depth; for a balanced tree of 1000 nodes, that's ~O(n) inserts.
Recursive CTE reads: each query traverses the tree at O(n) — acceptable for moderate frequency, but at 1000 QPS it adds up.
Materialised path (ltree): gives you fast prefix queries with cheap writes, but no FK enforcement and path string length can be unwieldy.
Key Takeaway
Recursive CTEs are the Swiss Army knife — they handle all patterns but aren't the fastest for any single one.
For read-heavy hierarchical data, a closure table beats recursive CTEs every time.
Match the pattern to your workload's read/write ratio, not your personal preference.
● Production incidentPOST-MORTEMseverity: high
The Infinite Loop That Killed Payroll Processing
Symptom
A monthly payroll query that usually ran in under 2 seconds started timing out. The database CPU spiked to 100% and the application pool exhausted its connection limit.
Assumption
The ETL pipeline had a data integrity check that prevented circular manager_id references. The team assumed the parent_id column was always a valid tree.
Root cause
A bug in an upstream system set an employee's manager_id to a value that created a cycle (A reports to B, B reports to C, C reports to A). The recursive CTE had no cycle detection and no MAXRECURSION guard — it looped until SQL Server's default 100 iterations, which took 45 seconds on a 200k-row table and blocked every query against the employee table.
Fix
Added a visited-IDs array guard in the recursive member and set OPTION (MAXRECURSION 50) as a safety net. Also implemented a database trigger to reject circular parent_id updates.
Key lesson
Never trust that parent_id data is cycle-free — always add explicit cycle detection inside the recursive CTE.
Always set MAXRECURSION to a value above your maximum legitimate tree depth, not to 0 (unlimited).
Production incident post-mortem: a 10-minute fix in the query would have saved 3 hours of downtime.
Production debug guideSymptom → Action guide for the three most common recursive CTE failures3 entries
Symptom · 01
Recursive CTE hits MAXRECURSION error or runs indefinitely
→
Fix
Check parent_id table for cycles with a self-join query or visited-IDs approach. Also verify that the recursive member's WHERE clause provably reduces the result set each iteration.
Symptom · 02
Query returns correct data but is 10x slower than expected
→
Fix
Run EXPLAIN ANALYZE and look for 'Seq Scan on employee' inside the recursive member. If present, create or verify an index on the parent_id column.
Symptom · 03
Result set contains duplicate rows or inflated aggregate values
→
Fix
Check if you used UNION instead of UNION ALL. UNION forces dedup which can hide cycles but also corrupts DAG traversals. Switch to UNION ALL and handle dedup explicitly if needed.
★ Recursive CTE Debugging Quick ReferenceThree immediate action cards for the most critical recursive CTE failures in production
CTE spins forever / timeout−
Immediate action
Kill the session immediately. Then check for circular parent_id.
Commands
SELECT employee_id, manager_id FROM employee WHERE manager_id IS NOT NULL GROUP BY employee_id, manager_id HAVING COUNT(*) > 1; -- detects multi-parent (not loops but a start)
WITH RECURSIVE check_cycle AS (SELECT employee_id, ARRAY[employee_id] AS visited FROM employee WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, c.visited || e.employee_id FROM employee e JOIN check_cycle c ON e.manager_id = c.employee_id WHERE NOT e.employee_id = ANY(c.visited)) SELECT * FROM check_cycle WHERE visited[array_upper(visited,1)] = ANY(visited[1:array_upper(visited,1)-1]); -- finds cycles
Fix now
Add visited-IDs array guard and set MAXRECURSION to a safe limit. Then update or delete the cycling row(s).
CTE slow despite correct results+
Immediate action
Check the execution plan for full table scans inside the recursive member.
Commands
EXPLAIN (ANALYZE, BUFFERS) WITH RECURSIVE ...; -- look for 'Seq Scan' on the table in the recursive branch
SELECT pg_size_pretty(pg_total_relation_size('employee')) as table_size; -- estimate if a seq scan is expensive
Fix now
CREATE INDEX idx_employee_manager_id ON employee(manager_id); -- retry the CTE
Duplicate rows in recursive CTE output+
Immediate action
Verify UNION vs UNION ALL in the CTE definition.
Commands
SELECT query_text FROM pg_stat_activity WHERE query ILIKE '%WITH RECURSIVE%'; -- find the actual query running
Check if output duplicates match a pattern: do they come from the same node reached via multiple paths?
Fix now
Change UNION to UNION ALL. If dedup needed, add DISTINCT in the outer query or use a visited-IDs array to skip revisited nodes.
Aspect
Recursive CTE
Closure Table
Nested Sets (Modified Preorder)
Storage overhead
None — just parent_id
High — O(n²) rows for a balanced tree
Low — 2 extra integer columns
Read performance (subtree)
Moderate — traversal at query time
Excellent — single range scan
Excellent — single range scan
Write performance (insert/move)
Excellent — just update parent_id
Poor — must update ancestor-descendant pairs
Very poor — rebalances left/right values for whole tree
Cycle safety
Manual detection required
N/A (no recursion)
N/A (no recursion)
Depth limit
MAXRECURSION / engine limit
Unlimited
Unlimited
Arbitrary depth queries
Native — that's its purpose
Native — just one join
Awkward — requires range arithmetic
Schema complexity
Very low — one FK column
Medium — separate table needed
Medium — two extra columns to maintain
Best used when
Tree depth < ~500, writes frequent
Reads dominate, tree rarely restructured
Reads dominate, structure rarely changes
PostgreSQL support
Full (v8.4+)
Manual implementation
Manual implementation
MySQL support
MySQL 8.0+ only
Manual implementation
Manual implementation
Key takeaways
1
Recursive CTEs use a worktable model, not true function recursion
the recursive member only ever sees the most recent batch of rows, not the full accumulated result. Understanding this explains why you carry state (depth, path, visited IDs) forward as columns.
2
An index on the parent_id (manager_id) column is mandatory, not optional. Without it, the recursive member does a full table scan on every single iteration
on a deep tree over a large table this escalates from milliseconds to minutes.
3
Always filter INSIDE the recursive member to prune traversal early. WHERE clauses in the outer SELECT run AFTER the entire CTE has been materialised
they don't reduce the work the recursion does.
4
Cycle detection must be explicit. Never assume your parent_id column is clean in production. The visited-IDs array guard (check if next_id = ANY(visited_ids)) works cross-engine and is the most robust pattern. PostgreSQL 14+ CYCLE clause is cleaner but ties you to one engine.
5
Recursive CTEs are not the fastest for all workloads. For read-heavy trees with deep levels, a closure table or materialised path often outperforms repeated CTE queries.
Common mistakes to avoid
3 patterns
×
Forgetting the termination condition
Symptom
Query runs until MAXRECURSION error or crashes the session; on MySQL with cte_max_recursion_depth exceeded you get 'ERROR 3636: Recursive query aborted'.
Fix
Always include a WHERE clause in the recursive member that provably reduces the result set each iteration. The most reliable guard is depth_level < N (where N is your known maximum tree depth) AND the join condition itself (which will return zero rows when there are no more children). Never rely on the join alone if your data might contain cycles.
×
Filtering in the outer SELECT instead of inside the recursive member
Symptom
Query returns correct results but takes 10–100x longer than it should; EXPLAIN shows 'WorkTable Scan' processing thousands of rows.
Fix
Move depth filters, subtree root filters, and any other pruning conditions INTO the WHERE clause of the recursive SELECT member. The CTE materialises every row it produces — the outer query filter runs after full materialisation. Pruning inside the recursion saves computation on every iteration.
×
Using UNION instead of UNION ALL in the recursive CTE
Symptom
Query is catastrophically slow or hangs; on SQL Server you get 'Recursive common table expressions are not allowed to use UNION' error — on PostgreSQL it silently deduplicates rows which corrupts results for graphs where the same node can be reached via multiple paths.
Fix
Always use UNION ALL between the anchor and recursive members. UNION ALL is the required syntax per SQL standard for recursive CTEs. If you need to deduplicate, do it in the final outer SELECT with DISTINCT or handle it with the visited-IDs cycle detection pattern.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Walk me through exactly how the SQL engine executes a recursive CTE — wh...
Q02SENIOR
You've got a product bill-of-materials table where some components are s...
Q03SENIOR
An engineer on your team says 'I'll just set MAXRECURSION 0 so the recur...
Q01 of 03SENIOR
Walk me through exactly how the SQL engine executes a recursive CTE — what's happening behind the scenes on each iteration?
ANSWER
Interviewers want to hear 'worktable', 'anchor runs once', 'recursive member sees only the latest batch', and 'stops when zero rows returned' — not just 'it calls itself'.
Q02 of 03SENIOR
You've got a product bill-of-materials table where some components are shared across multiple parent products. You write a recursive CTE to explode the full BOM and sum up component costs, but you're getting numbers that are too high. What's the likely bug and how do you fix it?
ANSWER
Shared components in a DAG get counted multiple times — once per path that reaches them. Fix: deduplicate with DISTINCT on component_id, or accumulate costs only at the leaf level, or use a closure table instead.
Q03 of 03SENIOR
An engineer on your team says 'I'll just set MAXRECURSION 0 so the recursive CTE never fails.' What's wrong with that decision and what would you do instead?
ANSWER
Red flag answer: 'sounds fine'. Green flag: explain that MAXRECURSION 0 removes the safety net, means a single circular reference in the data causes an infinite loop that consumes server resources until killed, and that the right fix is either a database constraint preventing cycles or explicit visited-ID cycle detection inside the query itself.
01
Walk me through exactly how the SQL engine executes a recursive CTE — what's happening behind the scenes on each iteration?
SENIOR
02
You've got a product bill-of-materials table where some components are shared across multiple parent products. You write a recursive CTE to explode the full BOM and sum up component costs, but you're getting numbers that are too high. What's the likely bug and how do you fix it?
SENIOR
03
An engineer on your team says 'I'll just set MAXRECURSION 0 so the recursive CTE never fails.' What's wrong with that decision and what would you do instead?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
What is the difference between a recursive CTE and a regular CTE in SQL?
A regular CTE is just a named subquery — it runs once and produces a static result set. A recursive CTE has two parts: an anchor (runs once to seed results) and a recursive member (runs repeatedly, each time joining back to the CTE's own output from the previous iteration). The engine repeats the recursive member until it produces zero new rows. Regular CTEs can't reference themselves; recursive CTEs are specifically designed to do exactly that.
Was this helpful?
02
Does MySQL support recursive CTEs?
Yes, but only from MySQL 8.0 onwards. If you're on MySQL 5.x or MariaDB before 10.2, recursive CTEs are not available and you'll need to use stored procedures with loops, or restructure your schema to use a closure table or nested sets model instead. Always check your MySQL version with SELECT VERSION() before writing recursive CTEs.
Was this helpful?
03
How do I stop a recursive CTE from running forever?
Three layers of protection working together: first, make sure the recursive member's JOIN condition naturally produces fewer rows each iteration (it will return zero rows when there are no more children to find). Second, add an explicit depth guard (WHERE depth_level < 500) inside the recursive member as a hard cap. Third, implement visited-ID tracking using an array column to detect and break cycles caused by dirty data. In SQL Server, you also get OPTION (MAXRECURSION N) as a final backstop — set it to a value just above your known maximum legitimate tree depth.
Was this helpful?
04
Can recursive CTEs handle directed acyclic graphs (DAGs) or only trees?
They can handle DAGs, but you must use UNION ALL (not UNION) and implement explicit visited-ID tracking to avoid processing the same node multiple times via different paths. Without proper cycle detectio.