Recursive SQL CTE — Missing Cycle Detection Crashed Payroll
A recursive CTE without cycle detection spiked CPU to 100%, blocking payroll for costly 3-hour downtime.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
A Recursive SQL CTE (Common Table Expression) is a query that references itself, enabling traversal of hierarchical or graph-structured data within a single SQL statement. Unlike iterative loops in procedural languages, recursive CTEs are set-based operations where the database engine repeatedly executes a union of an anchor member (the base case) and a recursive member (which joins back to the CTE's previous output) until no new rows are produced.
This pattern solves problems like organizational charts, bill-of-materials explosions, or tree traversals that would otherwise require multiple queries or application-level recursion. However, the term 'recursive' is misleading—SQL engines implement this as iterative expansion, not true recursion, which matters for understanding performance and termination guarantees.
Recursive CTEs exist because relational databases lack native loop constructs, yet hierarchical data is ubiquitous. Alternatives include nested sets, adjacency lists with application-side recursion, or graph databases like Neo4j for deeply connected data.
You should avoid recursive CTEs when depth is unbounded or cycles are possible without explicit detection—missing cycle handling can cause infinite loops that crash production systems, as seen in payroll scenarios where employee-manager relationships form cycles. The MAXRECURSION hint (e.g., OPTION (MAXRECURSION 100)) provides a safety net by limiting iterations, but it's a blunt instrument; proper cycle detection using a visited-node path column is essential for correctness.
In production, recursive CTEs can destroy database performance because each iteration materializes intermediate results, causing exponential memory and I/O growth if not tuned. For example, a 10-level deep org chart with 1,000 nodes per level generates 10^30 rows in the worst case.
Real-world patterns like aggregating subtrees (e.g., summing sales per region) require careful indexing on the join key and limiting recursion depth. When performance is critical, consider alternative patterns: materialized path columns for shallow hierarchies, closure tables for frequent subtree queries, or graph databases for complex traversals.
Recursive CTEs are powerful but demand disciplined use—they're a scalpel, not a sledgehammer.
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.
Why Recursive SQL CTEs Are Not Just 'Recursive'
A recursive SQL CTE (Common Table Expression) is a query that references itself, enabling traversal of hierarchical or graph-structured data within a single SQL statement. The core mechanic is a two-part definition: an anchor member that selects the base rows, and a recursive member that joins the CTE back to itself, iterating until no new rows are produced. This is fundamentally a fixed-point computation, not a loop — the database guarantees termination only if the recursion eventually yields zero rows.
In practice, the recursive member executes repeatedly, each iteration feeding its output as input to the next. The key property that matters: without explicit cycle detection, the recursion can run infinitely if the data contains cycles. Most databases (PostgreSQL, SQL Server, MySQL 8+) provide a CYCLE clause or MAXRECURSION hint to cap iterations, but many teams skip these. The default limit is often 100 iterations — fine for trees, fatal for graphs with cycles.
Use recursive CTEs when you need to query organizational hierarchies, bill-of-materials explosions, or graph paths like flight routes. They matter in real systems because they replace procedural code (loops, cursors) with set-based logic, but they introduce a sharp edge: a cyclic reference in production data can crash payroll processing by locking the database with an infinite query.
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.
-- ============================================================ -- SETUP: A realistic employee table with a self-referencing -- parent (manager) relationship. -- Tested on PostgreSQL 15, SQL Server 2019, MySQL 8.0+ -- ============================================================ CREATE TABLE employee ( employee_id INT PRIMARY KEY, full_name VARCHAR(100) NOT NULL, job_title VARCHAR(100) NOT NULL, manager_id INT NULL, -- NULL means this person is the root (CEO) FOREIGN KEY (manager_id) REFERENCES employee(employee_id) ); INSERT INTO employee (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. -- ============================================================ WITH RECURSIVE 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, 0 AS depth_level, -- CEO is at depth 0 full_name::TEXT AS reporting_path -- Path starts with just the CEO name FROM employee WHERE manager_id IS NULL -- The root: no manager above them UNION ALL -- ---- 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 path FROM employee e INNER JOIN 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 ORDER BY reporting_path; -- Path ordering gives us a clean depth-first visual
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.
-- ============================================================ -- 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. WITH RECURSIVE safe_org_traversal AS ( -- Anchor: start at CEO, initialise the visited_ids array SELECT employee_id, full_name, manager_id, 0 AS depth_level, ARRAY[employee_id] AS visited_ids, -- Track every ID we've seen FALSE AS cycle_detected -- Flag: are we in a loop? FROM employee WHERE manager_id IS NULL UNION ALL SELECT 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 before FROM employee e INNER JOIN safe_org_traversal sot ON e.manager_id = sot.employee_id WHERE NOT sot.cycle_detected -- STOP recursing if we already flagged a cycle AND 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 ORDER BY depth_level, employee_id; ROLLBACK; -- Undo the corrupted data -- ============================================================ -- APPROACH 2: PostgreSQL 14+ declarative CYCLE clause (cleaner) -- ============================================================ WITH RECURSIVE org_with_cycle_check AS ( SELECT employee_id, full_name, manager_id, 0 AS depth_level FROM employee WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.full_name, e.manager_id, owc.depth_level + 1 FROM employee e INNER JOIN org_with_cycle_check owc ON e.manager_id = owc.employee_id ) CYCLE employee_id -- Tell PostgreSQL WHICH column to watch for repeats SET is_cycle -- A boolean column PostgreSQL adds automatically USING cycle_path -- An array column PostgreSQL manages for you SELECT employee_id, full_name, depth_level, is_cycle FROM org_with_cycle_check ORDER BY 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
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.
-- ============================================================ -- PATTERN 1: Find ALL ancestors of a given employee (upward) -- Use case: Show the full chain of command above 'James Osei' -- ============================================================ WITH RECURSIVE ancestors_of_james AS ( -- Anchor: start AT James Osei (our leaf node) SELECT employee_id, full_name, job_title, manager_id, 0 AS levels_above FROM employee WHERE employee_id = 7 -- James Osei's ID UNION ALL -- Recursive: join UPWARD — find the manager of whoever we just found SELECT e.employee_id, e.full_name, e.job_title, e.manager_id, aj.levels_above + 1 FROM employee e INNER JOIN 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 ORDER BY 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) -- ============================================================ WITH RECURSIVE full_subtree AS ( -- Anchor: every direct report of the CEO (the VPs) -- We tag each person with the VP they ultimately roll up to SELECT employee_id, full_name, manager_id, employee_id AS subtree_root_id -- Each VP is the 'root' of their own subtree FROM employee WHERE manager_id = 1 -- Direct reports of Sandra (CEO) UNION ALL -- 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 to SELECT e.employee_id, e.full_name, e.manager_id, fs.subtree_root_id -- Preserve the root tag through all levels FROM employee e INNER JOIN full_subtree fs ON e.manager_id = fs.employee_id ) -- Now aggregate the flat list — standard GROUP BY, no recursion needed here SELECT 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) + 1 AS total_headcount_including_vp FROM full_subtree fs INNER JOIN employee root_emp ON fs.subtree_root_id = root_emp.employee_id GROUP BY root_emp.employee_id, root_emp.full_name, root_emp.job_title ORDER BY total_headcount_including_vp DESC;
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.
-- ============================================================ -- 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. CREATE INDEX IF NOT EXISTS idx_employee_manager_id ON employee (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, FORMAT TEXT) WITH RECURSIVE org_hierarchy AS ( SELECT employee_id, full_name, manager_id, 0 AS depth_level FROM employee WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.full_name, e.manager_id, oh.depth_level + 1 FROM employee e INNER JOIN 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 filters WITH RECURSIVE all_levels AS ( SELECT employee_id, full_name, manager_id, 0 AS depth_level FROM employee WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.full_name, e.manager_id, al.depth_level + 1 FROM employee e INNER JOIN 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 hit WITH RECURSIVE top_two_levels AS ( SELECT employee_id, full_name, manager_id, 0 AS depth_level FROM employee WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.full_name, e.manager_id, t2l.depth_level + 1 FROM employee e INNER JOIN 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 ORDER BY 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
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 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) CREATE TABLE category ( category_id INT PRIMARY KEY, name VARCHAR(100) NOT NULL ); -- Closure table: every ancestor-descendant pair + self-reference CREATE TABLE category_closure ( ancestor_id INT NOT NULL, descendant_id INT NOT NULL, depth INT NOT NULL, -- 0 means self-reference PRIMARY KEY (ancestor_id, descendant_id), FOREIGN KEY (ancestor_id) REFERENCES category(category_id), FOREIGN KEY (descendant_id) REFERENCES category(category_id) ); -- Insert categories INSERT INTO category VALUES (1, 'Electronics'), (2, 'Laptops'), (3, 'Phones'), (4, 'Gaming Laptops'); -- Insert closure relationships (setup for new node 4: ancestor path) INSERT INTO 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 = 1 AND cc.depth > 0; -- exclude self -- Get all ancestors of Gaming Laptops SELECT c.name AS ancestor, cc.depth FROM category c JOIN category_closure cc ON c.category_id = cc.ancestor_id WHERE cc.descendant_id = 4 AND cc.depth > 0; -- ============================================================ -- Compare: Recursive CTE query to do the same thing -- ============================================================ WITH RECURSIVE cat_tree AS ( SELECT category_id, name, 0 AS depth FROM category WHERE category_id = 1 UNION ALL SELECT c.category_id, c.name, ct.depth + 1 FROM 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
- 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.
The Recursive CTE Execution Model — Step by Step, No Magic
When that recursive CTE runs, the engine doesn't just wave a wand and produce hierarchy. It executes a fixed-point iteration, and understanding that saves you from infinite loops and performance disasters. The anchor member runs first, producing the initial rowset — the root nodes of your tree. That result gets shoved into a work table. Then the recursive member executes, joining the work table back to the source data. Each iteration replaces the work table with new rows, and the engine checks if any new rows were produced. If yes, repeat. If no, union all results and return. This is not an elegant depth-first traversal; it's a brute-force, breadth-first expansion. Every level adds an entire pass over the data. That's why a 10-level deep tree can generate 10 full table scans. Watch your execution plan. The spool operator is your enemy — it materializes intermediate results in tempdb, and when that spills to disk, your query dies. The fix: filter aggressively in the anchor, index the join columns, and never let the recursive member scan without a predicate.
-- io.thecodeforge -- Simulating recursive CTE execution phases with pseudo-output WITH OrgChart AS ( -- Anchor: roots SELECT EmployeeID, ManagerID, 0 AS Level FROM Employees WHERE ManagerID IS NULL UNION ALL -- Recursive: join back to work table SELECT e.EmployeeID, e.ManagerID, r.Level + 1 FROM Employees e INNER JOIN OrgChart r ON e.ManagerID = r.EmployeeID ) SELECT * FROM OrgChart OPTION (MAXRECURSION 10);
Example: Bill of Materials Explosion — Real Parts, Real Pain
You need to flatten a multi-level bill of materials. Every part has a parent assembly, and sub-assemblies have their own children. A recursive CTE handles this, but only if you model it right. The anchor pulls top-level assemblies — those without a parent. The recursive member joins child to parent, accumulating a path column to track the hierarchy. Watch the cardinality: each join multiplies rows, so a fan-out of 3 per level produces 3^n rows. That's not theoretical. A 12-level BOM with moderate fan-out can generate millions of rows. Use a WHERE clause in the recursive member to prune dead branches. Also, sum quantities along the path by multiplying in each recursion — don't do that in a later GROUP BY. The recursive CTE is the right tool when you need the full exploded list. But if you're counting unique parts or checking inventory, switch to a recursive function that accumulates state, or a temp table with a loop. The CTE materializes the entire tree; that's a feature for reporting, a bug for transactional updates.
-- io.thecodeforge WITH BOM AS ( -- Anchor: top-level assemblies SELECT PartID, ParentAssemblyID, Quantity, CAST(PartID AS VARCHAR(MAX)) AS Path, Quantity AS TotalQuantity FROM Parts WHERE ParentAssemblyID IS NULL UNION ALL -- Recursive: children with accumulated quantity SELECT p.PartID, p.ParentAssemblyID, p.Quantity, CAST(b.Path + '->' + CAST(p.PartID AS VARCHAR) AS VARCHAR(MAX)), b.TotalQuantity * p.Quantity FROM Parts p INNER JOIN BOM b ON p.ParentAssemblyID = b.PartID ) SELECT * FROM BOM WHERE TotalQuantity > 10 -- prune early OPTION (MAXRECURSION 100);
The Infinite Loop That Killed Payroll Processing
- 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.
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 cyclesEXPLAIN (ANALYZE, BUFFERS) WITH RECURSIVE ...; -- look for 'Seq Scan' on the table in the recursive branchSELECT pg_size_pretty(pg_total_relation_size('employee')) as table_size; -- estimate if a seq scan is expensiveSELECT query_text FROM pg_stat_activity WHERE query ILIKE '%WITH RECURSIVE%'; -- find the actual query runningCheck if output duplicates match a pattern: do they come from the same node reached via multiple paths?| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| employee_hierarchy_traversal.sql | CREATE TABLE employee ( | The Anatomy of a Recursive CTE |
| cycle_detection_safe_recursion.sql | BEGIN; | Cycle Detection and the MAXRECURSION Safety Net |
| subtree_aggregation_and_ancestor_lookup.sql | WITH RECURSIVE ancestors_of_james AS ( | Real-World Patterns |
| recursive_cte_performance_tuning.sql | CREATE INDEX IF NOT EXISTS idx_employee_manager_id | Performance Internals and Production Tuning |
| closure_table_example.sql | CREATE TABLE category ( | When to Avoid Recursive CTEs |
| execution_model.sql | WITH OrgChart AS ( | The Recursive CTE Execution Model |
| bom_explosion.sql | WITH BOM AS ( | Example: Bill of Materials Explosion |
Key takeaways
Common mistakes to avoid
3 patternsForgetting the termination condition
Filtering in the outer SELECT instead of inside the recursive member
Using UNION instead of UNION ALL in the recursive CTE
Interview Questions on This Topic
Walk me through exactly how the SQL engine executes a recursive CTE — what's happening behind the scenes on each iteration?
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?
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?
Frequently Asked Questions
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.
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.
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.
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.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's SQL Advanced. Mark it forged?
7 min read · try the examples if you haven't