Hook: Recursive CTEs are the SQL version of climbing a ladder one rung at a time: you start with a base row, then keep reusing the same rule until there are no more rows to add.
Question: What is a Recursive CTE?
Answer: A recursive CTE is a query that refers to itself so SQL can walk a hierarchy or repeated pattern, such as managers and employees, folders and subfolders, or parts and subparts. It has two parts: an anchor query that starts the result, and a recursive query that keeps finding the next level until no new rows appear.
Interview-Ready Answer: A recursive CTE is a CTE that builds a result set in steps. I start with one or more anchor rows, then the recursive member joins those rows back to the base table to find the next level, and SQL repeats that until the query returns no more rows. I like it because it turns a looping problem into one declarative query, and in real systems I use it for hierarchies like org charts, category trees, and bill of materials.
A CTE is a Common Table Expression: a named temporary result used inside one statement. A recursive CTE is special because the CTE name appears inside its own definition, letting SQL grow a result set one level at a time. In practice, you write an anchor member first, then a recursive member that uses the rows found so far.
UNION ALL so every discovered row is kept without duplicate elimination overhead.If you remember only one thing, remember this: anchor = start, recursive member = next step, empty step = stop.
Home > Electronics > Phones.Use a recursive CTE when the depth is not fixed ahead of time. If you only ever need two levels, a simple self-join is easier. If you need a whole tree, recursion is clearer and usually safer than writing many joins by hand.
| Approach | Best For | Trade-off |
|---|---|---|
| Recursive CTE | Unknown depth | More complex than a normal join |
| Self-join | Fixed depth | Breaks down as levels grow |
| Procedural loop | Custom logic | Less declarative, more code |
With a good index on the parent key, a tree walk is often close to O(n) in the number of reachable rows, because each row is visited once per level. Without an index on the join column, the recursive step may scan a large table repeatedly and drift toward much worse performance. Recursive CTEs also materialize intermediate rows, so very wide trees can use noticeable memory and temp space.
Guardrails differ by database. SQL Server defaults to MAXRECURSION 100 unless you override it. MySQL 8+ has cte_max_recursion_depth, which defaults to 1000. PostgreSQL does not impose a small built-in level cap in the same way, so a bad cycle can keep going until you cancel the query or hit resource limits. Also, if you need deterministic output, add a final ORDER BY; recursion itself does not promise presentation order.
UNION ALL keeps duplicates; UNION removes them but can be slower and can hide repeated paths.Mental model for interviews: SQL is not “calling a function” here. It is repeatedly taking the rows from the previous wave, applying one join step, and stopping only when the wave becomes empty.
Imagine an e-commerce catalog service that builds category breadcrumbs and “show all products under this branch” views. The data looks like a tree: Electronics → Phones → Android. A recursive CTE lets the service start at the root category and walk downward until all descendants are collected, which is perfect for navigation menus and search filters.
What goes wrong when people misunderstand recursion? A bad import creates a cycle, such as Phones pointing to Android and Android pointing back to Phones. Suddenly the breadcrumb query never finishes, the database throws a recursion-limit error or times out, and the API starts returning 500s. In logs you might see “maximum recursion depth exceeded,” “statement timeout,” or repeated execution of the same join, while users report that category pages load forever or show incomplete navigation. That is why production teams add indexes, test for cycles, and sometimes cap recursion depth as a safety belt.
-- Recursive CTE example: walk an employee hierarchy and show each person's full path.
-- This uses standard SQL features that work in PostgreSQL, SQLite, and MySQL 8+.
-- The sample also includes an "orphan" row so you can see an edge case:
-- a row whose manager_id points to no real parent will not appear in the tree.
DROP TABLE IF EXISTS employees;
CREATE TABLE employees (
employee_id INTEGER PRIMARY KEY,
manager_id INTEGER NULL,
employee_name TEXT NOT NULL
);
INSERT INTO employees (employee_id, manager_id, employee_name) VALUES
(1, NULL, 'Ava'),
(2, 1, 'Ben'),
(3, 1, 'Chloe'),
(4, 2, 'Diego'),
(5, 2, 'Esha'),
(6, 3, 'Finn'),
(7, 99, 'Ghost');
WITH RECURSIVE org AS (
-- Anchor member: start from the roots.
-- A root is any employee with no manager.
SELECT
employee_id,
manager_id,
employee_name,
0 AS depth,
CAST(employee_name AS TEXT) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive member: take the current wave of rows and find the next level down.
-- UNION ALL is usually faster than UNION because we do not ask SQL to deduplicate.
SELECT
e.employee_id,
e.manager_id,
e.employee_name,
o.depth + 1 AS depth,
o.path || ' > ' || e.employee_name AS path
FROM employees e
JOIN org o
ON e.manager_id = o.employee_id
WHERE o.depth < 10
-- The depth cap is a safety belt: it prevents runaway recursion if bad data creates a cycle.
-- It is not a substitute for cleaning the data, but it keeps one broken row from taking down the query.
)
SELECT
employee_id,
manager_id,
employee_name,
depth,
path
FROM org
ORDER BY path;
-- Edge case check: this query finds rows that point to a missing parent.
-- They are "orphans" and do not show up in the recursive tree above.
SELECT
e.employee_id,
e.employee_name,
e.manager_id
FROM employees e
WHERE e.manager_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM employees p
WHERE p.employee_id = e.manager_id
);Follow-up & Tricky Questions:
UNION ALL instead of UNION? Because UNION ALL keeps every row and avoids duplicate-check work, which makes recursion faster. UNION can be useful if you truly need deduplication, but it adds extra sorting or hashing cost and can change the meaning of the tree.MAXRECURSION. The best fix is clean relational data, because a recursion limit only stops the damage.manager_id or parent_id. That is the key lookup in almost every recursive step.ORDER BY if the result needs to be stable.UNION automatically make recursion safe? Not necessarily. It may suppress exact duplicates, but if your rows differ by depth or path, the cycle can still keep generating new distinct rows.ORDER BY inside the recursive member to control traversal? Usually not in the way people hope. The reliable place to sort is the final query, because recursion is about generating rows, not guaranteeing display order.Tricky / Gotcha Questions:
Common Mistakes:
UNION by habit. Correction: prefer UNION ALL unless you have a real deduplication requirement.parent_id or manager_id so each step does not rescan the whole table.ORDER BY if the UI or report needs predictable ordering.Memory Hook: Seed, climb, stop. The anchor is the seed, the recursive member is the climb, and the climb stops when no new rows appear.
Cheat Sheet:
anchor member = starting rows.recursive member = step that finds the next level.UNION ALL is the usual choice for speed.ORDER BY at the end if order matters.Practice Tasks:
Ben, not the whole tree.Ava > Ben > Diego and sort by that path.