Recursive CTE in SQL: Syntax, Step-by-Step Trace, and Cycle Guards
A recursive CTE references itself, letting you walk hierarchies, generate sequences, and traverse graphs in pure SQL. The structure is always the same: a base case (the "anchor" query), a UNION ALL, and a recursive case that references the CTE name. Without the base case there's nothing to start from; without a terminating join condition you get an infinite loop.
Quick answer: A recursive CTE is a WITH RECURSIVE query made of two parts joined by UNION ALL: an anchor query that produces the starting rows, and a recursive query that references the CTE itself. The engine re-runs the recursive part against the previous iteration's rows until it returns no new rows, making it the standard SQL tool for org charts, trees, and sequences.
The Anatomy of a Recursive CTE
WITH RECURSIVE cte_name AS (
-- 1. Anchor: rows that start the recursion
SELECT ...
FROM ...
WHERE ...
UNION ALL
-- 2. Recursive step: references cte_name
SELECT ...
FROM ...
JOIN cte_name ON ...
)
SELECT * FROM cte_name;
The recursive step runs over and over โ at each iteration it sees only the rows the previous iteration produced โ until it returns no new rows.
Example 1: Walking an Org Chart (Hierarchy)
Given a self-referencing employees table with a manager_id, list everyone who reports up to a given CEO.
WITH RECURSIVE reports AS (
-- Anchor: the CEO
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE id = 1
UNION ALL
-- Recursive step: anyone whose manager is already in `reports`
SELECT e.id, e.name, e.manager_id, r.depth + 1
FROM employees e
JOIN reports r ON e.manager_id = r.id
)
SELECT id, name, depth FROM reports ORDER BY depth, name;
The depth column makes it easy to indent or filter to direct reports vs the whole subtree.
How Does a Recursive CTE Actually Execute?
Iteratively, not by function-call recursion. The anchor runs once and its rows become the working set. Each iteration, the recursive step joins only against the rows produced by the previous iteration, appends its output to the result, and that output becomes the new working set. When an iteration produces zero rows, execution stops.
Trace it on a five-person org chart โ Priya (id 1, CEO), Marcus (2) and Elena (3) report to Priya, Jonah (4) reports to Marcus, Ava (5) reports to Jonah:
-- Iteration 0 (anchor): WHERE id = 1
-- -> (1, 'Priya', depth 0) working set: {1}
-- Iteration 1: employees whose manager_id IN (1)
-- -> (2, 'Marcus', depth 1)
-- -> (3, 'Elena', depth 1) working set: {2, 3}
-- Iteration 2: employees whose manager_id IN (2, 3)
-- -> (4, 'Jonah', depth 2) working set: {4}
-- Iteration 3: employees whose manager_id IN (4)
-- -> (5, 'Ava', depth 3) working set: {5}
-- Iteration 4: employees whose manager_id IN (5)
-- -> no rows recursion stops
-- Final result: all 5 rows, depths 0-3
Note that iteration 2 does not re-scan Priya's row โ only the previous iteration's rows ({2, 3}) feed the join. That's what keeps each step cheap and the whole walk linear in the number of reachable rows.
Example 2: Generating a Date Spine
Need every day in a range โ even days with zero events? Generate a date series, then left-join your facts.
WITH RECURSIVE date_spine AS (
SELECT DATE '2026-01-01' AS d
UNION ALL
SELECT d + INTERVAL '1 day'
FROM date_spine
WHERE d < DATE '2026-01-31'
)
SELECT
s.d,
COALESCE(COUNT(o.id), 0) AS orders
FROM date_spine s
LEFT JOIN orders o ON o.created_date = s.d
GROUP BY s.d
ORDER BY s.d;
Postgres has generate_series() for this; recursive CTEs are the portable fallback for SQL Server / MySQL / SQLite.
Example 3: Graph Traversal With Cycle Detection
For a graph (e.g., friend-of-friend), you must guard against cycles or the query will loop until it runs out of memory.
WITH RECURSIVE reachable AS (
SELECT
from_user AS user_id,
ARRAY[from_user] AS path, -- Postgres array
1 AS depth
FROM friendships
WHERE from_user = 100
UNION ALL
SELECT
f.to_user,
r.path || f.to_user,
r.depth + 1
FROM friendships f
JOIN reachable r ON f.from_user = r.user_id
WHERE NOT (f.to_user = ANY(r.path)) -- skip already-visited nodes
AND r.depth < 5 -- bound the search
)
SELECT DISTINCT user_id FROM reachable;
The path array is the cycle guard. The depth < 5 clause stops a runaway recursion even if the cycle check has a bug.
When Does a Recursive CTE Stop (and How Do You Prevent Infinite Loops)?
A recursive CTE stops the first time the recursive step returns zero rows โ there is no other built-in exit. You prevent infinite loops three ways: make the join condition genuinely narrow the data (child-to-parent, not parent-to-child), track visited rows with a path column for cyclic data, and add an explicit depth bound as a safety net. Three ways things go wrong:
- Cycles in the data โ A reports to B, B reports to A. Add a path tracker or a
WHERE depth < Nbound. - Cartesian recursive step โ joining without a proper key can multiply rows each iteration.
- Wrong direction โ joining on the parent side instead of child side never narrows the result. Trace one iteration on paper.
Which Databases Support Recursive CTEs โ and How Does the Syntax Differ?
All major engines support them: PostgreSQL, MySQL 8+, SQL Server, Oracle, SQLite, BigQuery, and Snowflake. The main syntax split is the keyword โ PostgreSQL, MySQL, and SQLite require WITH RECURSIVE, while SQL Server and Oracle use plain WITH. Recursion depth limits and cycle-detection helpers also vary by engine:
- PostgreSQL: requires
WITH RECURSIVE. SupportsSEARCHandCYCLEclauses (Postgres 14+) for built-in cycle detection. - MySQL 8+: requires
WITH RECURSIVE. Default recursion limit is 1000 โ adjust withSET cte_max_recursion_depth = .... - SQL Server: the keyword is just
WITH(noRECURSIVE). AddOPTION (MAXRECURSION 1000)at the end of the outer query, or0for unlimited. - SQLite: requires
WITH RECURSIVE. No depth limit by default โ write your own.
When Not to Use a Recursive CTE
- Fixed-depth hierarchies (e.g., always 3 levels) โ a chain of self-joins is faster.
- Heavy graph workloads โ recursive CTEs in a relational database don't index the recursion. A graph database or a materialized closure table will outperform them at scale.
- Date sequences in Postgres โ use
generate_series(); in BigQuery useUNNEST(GENERATE_DATE_ARRAY(...)).
Common Pitfalls
- Forgetting
UNION ALLvsUNION:UNIONdeduplicates each iteration, which is slow and changes semantics (see UNION vs UNION ALL). UseUNION ALLunless you specifically need dedup. - No anchor: the base case must reference real tables, not the CTE itself, or the whole recursion is empty.
- Different column counts/types in the two halves: the anchor and recursive halves must produce identical column sets โ same names, same types.
- Hidden infinite loops in dev: always set a
MAXRECURSION/ depth bound while developing, even if you remove it later.
New to CTEs in general? Start with CTEs vs subqueries for when the non-recursive form is the right tool.
Pro Tip: If you find yourself writing the same recursive CTE on the same table over and over, materialize it as a closure table โ a flat table of (ancestor, descendant, depth) pairs maintained on insert. Reads become a simple indexed lookup with no recursion at all.