Hook: Recursive joins are how SQL walks a tree one branch at a time, which is exactly why interviewers love them: they reveal whether you can think beyond a flat table.
Question: What are recursive joins in SQL, and when do you use them?
Answer: A recursive join is usually a WITH RECURSIVE query that repeatedly joins a table to itself so you can move through hierarchical data, like managers and employees or folders and subfolders. The first query block finds the starting rows, and the recursive block keeps finding the next level until there are no more matches. It is the SQL way to answer, 'show me everything under this root.'
Interview-Ready Answer: I use recursive joins when I need to walk a hierarchy, not just match one row to another. In SQL, that usually means a recursive CTE with an anchor query for the root rows and a recursive query that self-joins to fetch the next level. I would also mention that UNION ALL is usually preferred for speed, and I add a cycle guard or depth limit if bad data could create loops.
Detailed Explanation: In SQL, 'recursive joins' usually means a recursive common table expression, or recursive CTE. A CTE (common table expression) is a named temporary result set inside one query. 'Recursive' means that result set can reference itself, so the database can keep joining the current rows back to the source table until the hierarchy is fully explored.
This is different from a plain self-join. A self-join matches a table to itself once, which gives you one hop. Recursive joins keep going until the chain ends. That is why they are used for transitive closure, which means 'all reachable rows,' not just the immediate children.
| Approach | Best for | Strength | Weakness |
|---|---|---|---|
| Self-join | One level | Simple | No deep traversal |
| Recursive CTE | Full hierarchy | Set-based | Needs cycle care |
| App loop | Custom traversal | Flexible | More code, more round trips |
In a clean tree, recursive traversal is often close to O(V + E), where V is the number of visited nodes and E is the number of visited links. The engine only expands the current frontier, so this can be efficient with an index on the parent key, such as manager_id or parent_id. But if the data is messy, duplicates can multiply rows, and cycles can cause runaway growth.
That is why UNION ALL is the default choice in most interview answers: it avoids repeated duplicate elimination. UNION removes duplicates, but that extra dedup step can add sorting or hashing work on every iteration. On large hierarchies, that can be the difference between a query that finishes in milliseconds and one that drags under load.
Engine detail interviewers like: SQL Server defaults to MAXRECURSION 100, so very deep trees can fail unless you override it. PostgreSQL and SQLite use recursive CTEs too, but you still need a logical stop condition; the database is not a safety net for bad graph data.
Memory hook: Think 'seed, sprout, repeat' — plant one root row, let it sprout the next level, and keep repeating until the tree stops growing.
Real-World Example: Imagine an e-commerce catalog service that stores categories like Electronics > Phones > Android. The site needs a recursive query to show all subcategories under a department and to build breadcrumb navigation for the product page. A recursive CTE is perfect because the hierarchy can be many levels deep and changes at runtime.
What goes wrong: an admin accidentally updates one category so that it becomes its own ancestor through a bad parent link. The category page API starts timing out, the database log fills with long-running recursive queries, and users see spinning loaders or 500 errors when they browse the catalog. In the worst case, p95 latency jumps from tens of milliseconds to seconds because the query keeps expanding the same branch again and again until the engine hits a recursion safeguard or a statement timeout.
-- PostgreSQL-compatible example: a small hierarchy plus an orphan row.
-- The recursive CTE walks from the root downward, and the path check
-- prevents revisiting the same employee id if bad data creates a cycle.
DROP TABLE IF EXISTS employees;
CREATE TEMP TABLE employees (
employee_id INTEGER PRIMARY KEY,
employee_name TEXT NOT NULL,
manager_id INTEGER
);
INSERT INTO employees (employee_id, employee_name, manager_id) VALUES
(1, 'Ava', NULL),
(2, 'Ben', 1),
(3, 'Cara', 1),
(4, 'Dan', 2),
(5, 'Eli', 2),
(6, 'Fay', 3),
(7, 'Gus', 99); -- Edge case: orphan, because manager 99 does not exist.
WITH RECURSIVE org_tree AS (
-- Anchor member: start from roots, where no manager exists.
SELECT
employee_id,
employee_name,
manager_id,
0 AS depth,
CAST(employee_id AS TEXT) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive member: join each known row to its direct children.
SELECT
e.employee_id,
e.employee_name,
e.manager_id,
ot.depth + 1 AS depth,
ot.path || '>' || CAST(e.employee_id AS TEXT) AS path
FROM employees e
JOIN org_tree ot
ON e.manager_id = ot.employee_id
-- Cycle guard: if bad data creates a loop, do not revisit the same id.
WHERE POSITION('>' || CAST(e.employee_id AS TEXT) || '>' IN '>' || ot.path || '>') = 0
)
SELECT
employee_id,
employee_name,
manager_id,
depth,
path
FROM org_tree
ORDER BY depth, employee_id;
-- Edge case report: this row is not reachable from any root.
SELECT
e.employee_id,
e.employee_name,
e.manager_id
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.employee_id
WHERE e.manager_id IS NOT NULL
AND m.employee_id IS NULL
ORDER BY e.employee_id;Follow-up & Tricky Questions:
UNION ALL preferred here? It keeps every row and avoids the cost of duplicate elimination on each iteration. That is usually faster and matches tree traversal better.MAXRECURSION as a safeguard.depth or path column through the recursion, then sort in the outer query. Never depend on the raw output order.Common Mistakes:
UNION out of habit. Fix: prefer UNION ALL unless you truly need deduplication, because dedup can slow every iteration.depth, path, or another explicit key in the outer query.Memory Hook: 'Seed, sprout, repeat' — start with the root, grow one level at a time, and stop when nothing new appears.
Cheat Sheet:
WITH RECURSIVE, not a magical join keyword.UNION ALL is usually faster than UNION.Practice Tasks:
depth limit and a path column, then sort the final result as a tree.