Recursive CTEs for Hierarchies
Walk self-referencing trees like org charts and category paths.
Why you can't just "join N times"
Some tables point at themselves. An employees row has a manager that is another emp_id; a categories row has a parent_id that is another id. That models a tree of unknown depth: Electronics > Audio > Headphones > Over-ear today, one level deeper tomorrow. To walk it with plain joins you would need one JOIN per level, but you do not know the level count when you write the query. A recursive CTE solves this by applying one query to its own output over and over until it stops producing rows.
The mental model: anchor, recursive member, working table
Read WITH RECURSIVE as a loop with three moving parts.
WITH RECURSIVE org AS (
-- ANCHOR: the seed rows, run once
SELECT emp_id, name, 0 AS depth
FROM employees
WHERE manager IS NULL -- the CEO(s)
UNION ALL
-- RECURSIVE MEMBER: run repeatedly, one level deeper each pass
SELECT e.emp_id, e.name, o.depth + 1
FROM employees e
JOIN org o ON e.manager = o.emp_id
)
SELECT * FROM org ORDER BY depth, emp_id;
-- Ada 0 | Bob 1 | Cy 1 | Di 2 | Fay 2 | Eve 3
The engine keeps a working table. The anchor fills it once. Then each pass runs the recursive member against only the rows the previous pass produced, not the whole accumulated result, appends the new rows, and repeats. When a pass yields zero rows (every leaf is reached), the loop stops. That "previous pass only" rule is the whole model: it is why depth + 1 counts levels correctly and why a clean tree terminates on its own.
Carry values down the recursion
Anything you want per node, compute it in the anchor and thread it through the recursive member. A breadcrumb plus its root ancestor:
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, name AS breadcrumb, name AS root_name, 0 AS depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id,
t.breadcrumb || ' > ' || c.name, -- append this level's name
t.root_name, -- copy the root down unchanged
t.depth + 1
FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT breadcrumb, root_name, depth FROM tree ORDER BY depth, id;
-- Electronics > Audio > Headphones | Electronics | 2
breadcrumb grows one segment per level; root_name, set once in the anchor, rides along untouched.
Pitfalls
- Cycles loop forever. If dirty data has
A's parent asBandB's parent asA, no pass ever returns zero rows. SQLite will not rescue you here: it has no default recursion-depth limit, so an unguarded cycle runs until it exhausts memory (in the browser it just freezes the tab). Add your own guard in the recursive member,WHERE t.depth < 20, so it stops. - Join direction decides up vs down.
JOIN ... ON c.parent_id = t.idwalks parents to children (down). Flip it toc.id = t.parent_idand you climb toward the root instead. Getting this backward is the classic first bug. - Use
UNION ALL, notUNION.UNIONdeduplicates on every pass, which is wasted work on a tree and can hide a cycle you wanted the depth cap to catch. - Anchor and recursive member must line up in column count and order, and each column's type is fixed by the anchor. On strict engines a too-narrow anchor text column truncates the growing
breadcrumb.
Interview nuance: a recursive CTE is not "the CTE can read all of its own rows." Each iteration joins against only the immediately preceding iteration's output (the working table), and default evaluation is breadth-first: all of depth 1, then all of depth 2. That semantics is why depth comes out exact, why the result order without an explicit ORDER BY is level order rather than id order, and why an unbounded self-reference on cyclic data never converges.
Portability: Postgres, MySQL, and BigQuery require the
RECURSIVEkeyword. SQL Server and Oracle omit it (plainWITH cte AS (...)). SQLite accepts it either way. WriteWITH RECURSIVEregardless: it is required on the engines that demand it and documents intent on the ones that do not.
Execution mode: you write a multi-statement script against a fresh in-memory SQLite database that already holds the source tree plus an empty target table; hidden queries then check depths, breadcrumbs, and row counts. Lead your load with DELETE FROM <target>; so a re-run stays idempotent.
Apply
Your turn
The task this lesson builds to.
Traverse an employees → manager hierarchy and record each employee's depth from the
top. Populate org_depth(emp_id, name, depth) where the CEO (no manager) is depth 0, their direct
reports are depth 1, and so on.
The employees table is seeded; the empty org_depth target already exists. Lead your load with
DELETE FROM org_depth; so re-running the script keeps exactly six rows.
3 hints and 5 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
From a self-referencing categories table, build a catalog mart
category_path(category_id, name, breadcrumb, depth, root_name):
breadcrumbis the full path from the root joined by' > '(e.g.Electronics > Audio > Headphones),depthis the level (root = 0),root_nameis the top-level ancestor's name.
Guard against a cyclic row in the data by capping depth at 20 in the recursive member. The
categories source is seeded and the empty category_path target already exists. Lead your load
with DELETE FROM category_path; so a re-run keeps exactly eight rows.
4 hints and 7 automated checks are waiting in the workspace.