Skip to main content
PProfileMatch

SQL CTEs and Recursive Queries: Walk Hierarchies with WITH RECURSIVE

Data AnalyticsLesson 2 of 48 min read

What you'll learn

  • Rewrite nested subqueries as readable, named CTEs
  • Explain the anchor, the recursive step and the stopping condition of WITH RECURSIVE
  • Walk a hierarchy while carrying a path, a depth and a multiplied quantity
  • Protect recursive queries against cycles with a depth limit or a visited-path check

On this page

  1. CTEs: giving a subquery a name
  2. Recursion: an anchor and a step
  3. Costing the whole bike
  4. Generating rows that do not exist
  5. Cycles: how a recursive query never ends
  6. Common mistakes
  7. How this shows up in real projects and interviews

A bicycle workshop wants to know how many spokes, rims and hubs one finished bike needs, and what the parts cost. A bike contains two wheels, each wheel contains 32 spokes, a rim, a hub and a tyre, and the frame and drivetrain are assemblies of their own parts. How deep the nesting goes is not fixed: tomorrow someone adds a "brake assembly" with a sub-assembly inside it. Plain joins need one JOIN per level, so they cannot express "keep going until there is nothing left". A recursive CTE can.

CTEs: giving a subquery a name

A common table expression (CTE) is a named, temporary result defined with WITH name AS (...) and used like a table in the query that follows. Everything it can do, a subquery in FROM can do too, but the name lets you read a query top to bottom in steps instead of inside out. It also lets you refer to one result several times. The schema below models a bill of materials with two tables: parts, and a bom table listing which part contains which, and how many.

bike_parts.sql
CREATE TABLE parts (
  part_id         INTEGER PRIMARY KEY,
  name            TEXT NOT NULL,
  unit_cost_cents INTEGER          -- NULL for assemblies; their cost comes from their parts
);

CREATE TABLE bom (                 -- bill of materials: which part contains which
  parent_id INTEGER NOT NULL REFERENCES parts(part_id),
  child_id  INTEGER NOT NULL REFERENCES parts(part_id),
  qty       INTEGER NOT NULL CHECK (qty > 0),
  PRIMARY KEY (parent_id, child_id)
);

INSERT INTO parts VALUES
  (1, 'Bicycle', NULL), (2, 'Frame', NULL), (3, 'Wheel', NULL), (4, 'Drivetrain', NULL),
  (5, 'Rim', 1800), (6, 'Spoke', 20), (7, 'Tyre', 1200), (8, 'Hub', 2500),
  (9, 'Chain', 1500), (10, 'Crankset', 4200), (11, 'Cassette', 3100),
  (12, 'Fork', 5500), (13, 'Tube set', 9000);

INSERT INTO bom VALUES
  (1, 2, 1), (1, 3, 2), (1, 4, 1),
  (2, 12, 1), (2, 13, 1),
  (3, 5, 1), (3, 6, 32), (3, 7, 1), (3, 8, 1),
  (4, 9, 1), (4, 10, 1), (4, 11, 1);

The first CTE computes each assembly's direct cost, the cost of its immediate costed children only.

sql
WITH direct_cost AS (
  SELECT b.parent_id,
         SUM(b.qty * c.unit_cost_cents) AS cents
  FROM bom AS b
  JOIN parts AS c ON c.part_id = b.child_id
  WHERE c.unit_cost_cents IS NOT NULL
  GROUP BY b.parent_id
)
SELECT p.name, d.cents
FROM direct_cost AS d
JOIN parts AS p ON p.part_id = d.parent_id
ORDER BY d.cents DESC;

Output

name       | cents
-----------+------
Frame      | 14500
Drivetrain |  8800
Wheel      |  6140

The wheel's 6140 cents is a rim (1800), 32 spokes at 20, a tyre (1200) and a hub (2500). It does not include anything inside the frame or drivetrain, and the bicycle itself does not appear because its children are all assemblies with no cost of their own. Answering the real question needs recursion.

Recursion: an anchor and a step

A recursive CTE has two parts joined by UNION ALL. The anchor query runs once and produces the starting rows. The recursive step refers to the CTE's own name, and runs repeatedly: each time it takes the rows produced in the previous round and finds their children. The process stops when a round produces no new rows. Think of it as a queue: the anchor seeds it, and the step keeps replacing each row with its children until the leaves are reached.

The query below carries three extra columns down the tree: a path string for sorting and display, a depth, and qty, the number of that part needed per bicycle, obtained by multiplying quantities along the way.

sql
WITH RECURSIVE explode(part_id, name, path, depth, qty) AS (
  -- anchor: start from the finished bicycle
  SELECT part_id, name, name, 0, 1
  FROM parts
  WHERE part_id = 1

  UNION ALL

  -- recursive step: add the children of every row found so far
  SELECT b.child_id, p.name, e.path || ' > ' || p.name, e.depth + 1, e.qty * b.qty
  FROM explode AS e
  JOIN bom   AS b ON b.parent_id = e.part_id
  JOIN parts AS p ON p.part_id   = b.child_id
)
SELECT substr('        ', 1, depth * 2) || name AS part, depth, qty AS per_bicycle
FROM explode
ORDER BY path;

Output

part         | depth | per_bicycle
-------------+-------+------------
Bicycle      |     0 |           1
  Drivetrain |     1 |           1
    Cassette |     2 |           1
    Chain    |     2 |           1
    Crankset |     2 |           1
  Frame      |     1 |           1
    Fork     |     2 |           1
    Tube set |     2 |           1
  Wheel      |     1 |           2
    Hub      |     2 |           2
    Rim      |     2 |           2
    Spoke    |     2 |          64
    Tyre     |     2 |           2

The indentation shows the hierarchy. The wheel line says qty = 2, since a bike has two wheels, and its spokes show 64, which is 2 wheels times 32 spokes per wheel: the multiplication happens in e.qty * b.qty. Sorting by path lists each assembly directly above its parts. The query never mentions how many levels exist, so an extra layer of sub-assemblies would be picked up automatically.

Part of the queryRoleIf it is wrong
Anchor SELECTSeeds the walk (the root, or the starting rows)Empty anchor means an empty result
UNION ALLAppends each round's rows to the outputUsing UNION removes duplicate rows, which can hide legitimate repeats or, sometimes, end a loop
Recursive SELECTFinds the next level from the previous roundJoining on the wrong key can loop forever or skip levels
Column list explode(...)Names the columns; the anchor and the step must match in number and typeMismatch errors, or the wrong types silently widened
Stopping conditionA round that yields no rows (and any WHERE you add)No natural end on cyclic data: runs until stopped

Costing the whole bike

With the exploded list available as a CTE, the costing question is an ordinary join and aggregate. Only parts with a unit cost are real, physical items.

sql
WITH RECURSIVE explode(part_id, name, path, depth, qty) AS (
  -- anchor: start from the finished bicycle
  SELECT part_id, name, name, 0, 1
  FROM parts
  WHERE part_id = 1

  UNION ALL

  -- recursive step: add the children of every row found so far
  SELECT b.child_id, p.name, e.path || ' > ' || p.name, e.depth + 1, e.qty * b.qty
  FROM explode AS e
  JOIN bom   AS b ON b.parent_id = e.part_id
  JOIN parts AS p ON p.part_id   = b.child_id
)
SELECT p.name, e.qty AS per_bicycle, p.unit_cost_cents AS each_cents,
       e.qty * p.unit_cost_cents AS line_cents
FROM explode AS e
JOIN parts AS p ON p.part_id = e.part_id
WHERE p.unit_cost_cents IS NOT NULL
ORDER BY line_cents DESC;

Output

name     | per_bicycle | each_cents | line_cents
---------+-------------+------------+-----------
Tube set |           1 |       9000 |       9000
Fork     |           1 |       5500 |       5500
Hub      |           2 |       2500 |       5000
Crankset |           1 |       4200 |       4200
Rim      |           2 |       1800 |       3600
Cassette |           1 |       3100 |       3100
Tyre     |           2 |       1200 |       2400
Chain    |           1 |       1500 |       1500
Spoke    |          64 |         20 |       1280
sql
WITH RECURSIVE explode(part_id, name, path, depth, qty) AS (
  -- anchor: start from the finished bicycle
  SELECT part_id, name, name, 0, 1
  FROM parts
  WHERE part_id = 1

  UNION ALL

  -- recursive step: add the children of every row found so far
  SELECT b.child_id, p.name, e.path || ' > ' || p.name, e.depth + 1, e.qty * b.qty
  FROM explode AS e
  JOIN bom   AS b ON b.parent_id = e.part_id
  JOIN parts AS p ON p.part_id   = b.child_id
)
SELECT SUM(e.qty * p.unit_cost_cents) AS bicycle_cents,
       SUM(CASE WHEN p.unit_cost_cents IS NOT NULL THEN e.qty END) AS physical_parts
FROM explode AS e
JOIN parts AS p ON p.part_id = e.part_id;

Output

bicycle_cents | physical_parts
--------------+---------------
        35580 |             75

A bicycle needs 75 physical parts and costs 35,580 cents in components, the sum of the line costs. Assemblies have a NULL cost, so multiplying by their quantity yields NULL and SUM skips those rows, the NULL behaviour covered in SQL Fundamentals.

Generating rows that do not exist

Recursion is also the portable way to create a sequence, for example a calendar to LEFT JOIN against so that days without data still appear. The anchor is the first date; each step adds a day while the date is before the end.

sql
WITH RECURSIVE days(day) AS (
  SELECT '2025-06-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM days WHERE day < '2025-06-07'
)
SELECT day, strftime('%w', day) AS weekday_no
FROM days;

Output

day        | weekday_no
-----------+-----------
2025-06-01 | 0
2025-06-02 | 1
2025-06-03 | 2
2025-06-04 | 3
2025-06-05 | 4
2025-06-06 | 5
2025-06-07 | 6

This ran on SQLite, which has the date() function used here. PostgreSQL offers generate_series for the same purpose, and SQL Server has its own date functions, so the recursion pattern ports but the date arithmetic does not.

Cycles: how a recursive query never ends

Real hierarchies sometimes contain mistakes. Suppose someone records that a Cassette contains a Bicycle. The data now has a cycle: bicycle, drivetrain, cassette, bicycle, and so on. A recursive walk with no guard would keep finding new rows forever, because every round produces rows.

sql
-- A data-entry mistake: someone records that a Cassette contains a Bicycle
INSERT INTO bom VALUES (11, 1, 1);

The simplest protection is a depth limit in the recursive part's WHERE. It does not repair the data, but it guarantees termination, as the first query below shows. A better guard records the path walked so far and refuses to enter a part that is already on it. After that guard, the cycle edge is simply never followed.

sql
WITH RECURSIVE walk(part_id, depth) AS (
  SELECT 1, 0
  UNION ALL
  SELECT b.child_id, w.depth + 1
  FROM walk AS w
  JOIN bom AS b ON b.parent_id = w.part_id
  WHERE w.depth < 8              -- safety net: stop after 8 levels
)
SELECT MAX(depth) AS deepest, COUNT(*) AS rows_produced FROM walk;

Output

deepest | rows_produced
--------+--------------
      8 |            39
sql
WITH RECURSIVE walk(part_id, trail, depth) AS (
  SELECT 1, ',1,', 0
  UNION ALL
  SELECT b.child_id, w.trail || b.child_id || ',', w.depth + 1
  FROM walk AS w
  JOIN bom AS b ON b.parent_id = w.part_id
  WHERE instr(w.trail, ',' || b.child_id || ',') = 0   -- never revisit a part already on this path
)
SELECT COUNT(*) AS rows_produced, MAX(depth) AS deepest FROM walk;

Output

rows_produced | deepest
--------------+--------
           13 |       2

With the depth guard the walk made it eight levels down and returned 39 rows, most of them junk produced by going round the loop. With the visited-path check, it returned the 13 rows the real tree holds, since the cycle edge added nothing new. PostgreSQL 14 and later also offers a CYCLE clause that automates this, but the manual version above works everywhere.

Common mistakes

  • No termination guard on cyclic data. Symptom: the query runs until you cancel it or the server runs out of memory. Use a depth limit or a visited-path check.
  • Missing the RECURSIVE keyword. PostgreSQL and MySQL require WITH RECURSIVE; leaving it out gives an error such as relation "explode" does not exist. SQL Server and Oracle do not use the keyword at all.
  • Anchor and step with different columns. Error: the number of columns must match. The recursive step must return exactly the same list in the same order.
  • Treating a CTE as a performance hint. Depending on the database and version, a CTE may be inlined or materialised; the keyword itself does not make a slow query fast. Check the query plan.
  • Double counting in a shared-part structure. If a part appears under two parents, the walk returns it twice, which is correct for quantities and wrong for a distinct list. Decide which you need and use DISTINCT deliberately.

How this shows up in real projects and interviews

Recursive CTEs appear wherever data nests: category trees, threaded comments, organisation charts, folder structures, bills of materials and dependency graphs. In interviews they come up as "print all reports under a manager" or "find all ancestors of a node", and the usual follow-up is "what if the data has a cycle?". Non-recursive CTEs matter every day: teams use them to break a long query into named steps that colleagues can review. When a recursive walk is slow, the join column in the recursive step (bom.parent_id here) is the first place to look for a missing index; see Database Indexing.

Try it yourself

Using the bike parts schema (before the cycle edge is added), write a recursive query that starts at the Drivetrain (part 4) and returns every part below it with its depth. Then write a second recursive query that goes upward: starting from the Spoke (part 6), list every assembly that directly or indirectly contains it, in order from nearest to farthest. Check that the Bicycle is the last row.

Show a hint

For the upward walk, anchor on part 6 and make the recursive step join bom on b.child_id = w.part_id, selecting b.parent_id. Carry depth and order by depth.

Key takeaways

  • A CTE names an intermediate result so a query reads top to bottom; it can be referenced several times.
  • A recursive CTE is an anchor plus a recursive step joined by UNION ALL; it ends when a round adds no rows.
  • Carry path, depth and any multiplied quantity as extra columns to display hierarchies and aggregate along them.
  • Always bound recursion on untrusted data, with a depth limit or a visited-path check.

Test yourself

A trick from this lesson's ride: A CTE is a name tag on a subquery, not a stored table. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is a CTE in SQL and when should I use it?

A common table expression is a named temporary result defined with WITH that the following query can use like a table. Use it to split a complex query into readable steps, to reuse a result several times, and as the basis of recursive queries.

How does a recursive CTE work?

It has an anchor query that produces the starting rows and a recursive query, joined with UNION ALL, that refers to the CTE itself. The database repeats the recursive part on the rows from the previous round and stops when a round returns nothing.

How do I stop an infinite loop in a recursive SQL query?

Add a depth limit in the recursive WHERE clause, or track the path of visited ids and skip any id already on the path. Some databases add features such as a CYCLE clause or a configurable recursion limit, but the explicit guards work everywhere.