<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Grouping and subqueries
All lessons in this course

Lesson 29 of 54 18 minIntermediate

Recursive CTEs: hierarchies and bills of materials

A CTE that refers to itself: the anchor and recursive members, generating numbers and dates, walking down and up a management hierarchy, exploding a bill of materials, and MAXRECURSION.

A recursive CTE refers to its own name. It has two parts joined by UNION ALL: an anchor query that produces the starting rows, and a recursive query that produces new rows from the rows of the previous step. It repeats until a step produces no rows.

Query.sql
WITH Numbers AS (    SELECT 1 AS n            -- anchor: the first row    UNION ALL    SELECT n + 1             -- recursive: one new row from the last one    FROM Numbers    WHERE n < 5              -- stops when this is false)SELECT n FROM Numbers;
Output
n
-
1
2
3
4
5
StepInput rowsOutput rows
anchor(none)n = 1
1n = 1n = 2
2n = 2n = 3
3n = 3n = 4
4n = 4n = 5
5n = 5none, because 5 < 5 is false: stop

Each step only sees the rows the previous step produced, not the whole result so far. The final result is all the steps stacked together.

Filling in missing months

The GROUP BY lesson showed that January has no orders and so no row. Generate every month of the half-year first, then LEFT JOIN the orders to it:

Query.sql
WITH Months AS (    SELECT CAST('2024-01-01' AS date) AS MonthStart    UNION ALL    SELECT DATEADD(month, 1, MonthStart)    FROM Months    WHERE MonthStart < '2024-06-01')SELECT m.MonthStart, COUNT(o.OrderId) AS OrdersFROM Months AS mLEFT JOIN dbo.Orders AS o    ON o.OrderDate >= m.MonthStart   AND o.OrderDate <  DATEADD(month, 1, m.MonthStart)GROUP BY m.MonthStartORDER BY m.MonthStart;
Output
Warning: Null value is eliminated by an aggregate or other SET operation.

MonthStart  Orders
----------  ------
2024-01-01       0
2024-02-01       2
2024-03-01       2
2024-04-01       2
2024-05-01       2
2024-06-01       2

Walking down a hierarchy

A table that refers to itself, such as staff with a ManagerId, holds a tree. A plain self join finds one level; a recursive CTE finds all of them:

Query.sql
CREATE TABLE dbo.Staff (    StaffId   int          NOT NULL PRIMARY KEY,    Name      nvarchar(40) NOT NULL,    JobTitle  nvarchar(40) NOT NULL,    ManagerId int          NULL REFERENCES dbo.Staff (StaffId));INSERT INTO dbo.Staff VALUES    (1, N'Maya',  N'Managing director', NULL),    (2, N'Omar',  N'Store manager',     1),    (3, N'Lena',  N'Finance manager',   1),    (4, N'Tom',   N'Yard supervisor',   2),    (5, N'Priya', N'Sales lead',        2),    (6, N'Sam',   N'Forklift driver',   4),    (7, N'Kofi',  N'Sales assistant',   5),    (8, N'Ruth',  N'Accounts clerk',    3);

Start from the person at the top (no manager) and, at each step, find the people whose manager was found in the step before:

Query.sql
WITH Org AS (    SELECT StaffId, Name, JobTitle, 0 AS Lvl,           CAST(Name AS nvarchar(200)) AS Path    FROM dbo.Staff    WHERE ManagerId IS NULL    UNION ALL    SELECT s.StaffId, s.Name, s.JobTitle, o.Lvl + 1,           CAST(o.Path + N' > ' + s.Name AS nvarchar(200))    FROM dbo.Staff AS s    JOIN Org AS o ON s.ManagerId = o.StaffId)SELECT REPLICATE(N'    ', Lvl) + Name AS Person, JobTitle, Lvl, PathFROM OrgORDER BY Path;
Output
Person            JobTitle           Lvl  Path
----------------  -----------------  ---  --------------------------
Maya              Managing director    0  Maya
    Lena          Finance manager      1  Maya > Lena
        Ruth      Accounts clerk       2  Maya > Lena > Ruth
    Omar          Store manager        1  Maya > Omar
        Priya     Sales lead           2  Maya > Omar > Priya
            Kofi  Sales assistant      3  Maya > Omar > Priya > Kofi
        Tom       Yard supervisor      2  Maya > Omar > Tom
            Sam   Forklift driver      3  Maya > Omar > Tom > Sam
  • Lvl counts the steps from the top: the anchor is 0, and each recursive step adds 1.
  • Path builds the chain of names. Sorting by it puts everyone under their manager, and REPLICATE indents them by level.
  • The CAST to nvarchar(200) matters: the anchor and recursive parts must have exactly the same type, and Name alone is only nvarchar(40).

Walking up

Turn the join around to go from one person up to the top. Start with Kofi and, at each step, find the manager of the previous row:

Query.sql
WITH Chain AS (    SELECT StaffId, Name, ManagerId, 0 AS StepsUp    FROM dbo.Staff    WHERE Name = N'Kofi'    UNION ALL    SELECT m.StaffId, m.Name, m.ManagerId, c.StepsUp + 1    FROM dbo.Staff AS m    JOIN Chain AS c ON m.StaffId = c.ManagerId)SELECT StepsUp, Name FROM Chain;
Output
StepsUp  Name
-------  -----
      0  Kofi
      1  Priya
      2  Omar
      3  Maya

Exploding a bill of materials

A bill of materials (BOM) says what each thing is made of. BuildMart sells a garden shed kit. The kit is made of panels, and the panels are made of products off the shelf:

Query.sql
CREATE TABLE dbo.Parts (    PartId    int          NOT NULL PRIMARY KEY,    Name      nvarchar(40) NOT NULL,    ProductId int          NULL REFERENCES dbo.Products (ProductId)  -- set for shelf items);CREATE TABLE dbo.Bom (    ParentId int NOT NULL REFERENCES dbo.Parts (PartId),    ChildId  int NOT NULL REFERENCES dbo.Parts (PartId),    Qty      int NOT NULL,    PRIMARY KEY (ParentId, ChildId));INSERT INTO dbo.Parts VALUES    (1, N'Garden shed kit', NULL), (2, N'Wall panel', NULL),    (3, N'Roof panel', NULL),      (4, N'Floor', NULL),    (5, N'Timber 2x4 2.4 m', 6),   (6, N'Plywood sheet 18 mm', 7),    (7, N'Wood screws (200)', 8);INSERT INTO dbo.Bom VALUES    (1, 2, 4), (1, 3, 2), (1, 4, 1), (1, 7, 3),    (2, 5, 5), (2, 6, 1),    (3, 5, 3), (3, 6, 1),    (4, 5, 6), (4, 6, 2);

Exploding the kit means following the tree down and multiplying the quantities on the way: 4 wall panels each need 5 lengths of timber, so the kit needs 20 for its walls.

Query.sql
WITH Explode AS (    SELECT b.ParentId, b.ChildId, b.Qty AS TotalQty, 1 AS Lvl    FROM dbo.Bom AS b    WHERE b.ParentId = 1    UNION ALL    SELECT b.ParentId, b.ChildId, e.TotalQty * b.Qty, e.Lvl + 1    FROM dbo.Bom AS b    JOIN Explode AS e ON b.ParentId = e.ChildId)SELECT e.Lvl, par.Name AS PartOf, pt.Name, e.TotalQtyFROM Explode AS eJOIN dbo.Parts AS pt  ON pt.PartId  = e.ChildIdJOIN dbo.Parts AS par ON par.PartId = e.ParentIdORDER BY e.Lvl, par.Name, pt.Name;
Output
Lvl  PartOf           Name                 TotalQty
---  ---------------  -------------------  --------
  1  Garden shed kit  Floor                       1
  1  Garden shed kit  Roof panel                  2
  1  Garden shed kit  Wall panel                  4
  1  Garden shed kit  Wood screws (200)           3
  2  Floor            Plywood sheet 18 mm         2
  2  Floor            Timber 2x4 2.4 m            6
  2  Roof panel       Plywood sheet 18 mm         2
  2  Roof panel       Timber 2x4 2.4 m            6
  2  Wall panel       Plywood sheet 18 mm         4
  2  Wall panel       Timber 2x4 2.4 m           20
Interactive

Exploding the shed kit

  • Garden shed kit
    • Wall panel ×4
      • …
      • …
    • Roof panel ×2
      • …
      • …
    • Floor ×1
      • …
      • …
    • Wood screws (200) ×3
LvlPartOfNameTotalQty
11Garden shed kitFloor1
21Garden shed kitRoof panel2
31Garden shed kitWall panel4
41Garden shed kitWood screws (200)3
The anchor reads the Bom rows whose parent is the kit (part 1): 4 rows at level 1. TotalQty is just Qty here.

Timber appears three times, once under each panel. To get a shopping list, keep only the shelf items, add up each one, and price it from Products:

Query.sql
WITH Explode AS (    SELECT b.ChildId, b.Qty AS TotalQty    FROM dbo.Bom AS b    WHERE b.ParentId = 1    UNION ALL    SELECT b.ChildId, e.TotalQty * b.Qty    FROM dbo.Bom AS b    JOIN Explode AS e ON b.ParentId = e.ChildId)SELECT pr.Name, SUM(e.TotalQty) AS Qty, pr.Price,       SUM(e.TotalQty) * pr.Price AS CostFROM Explode AS eJOIN dbo.Parts    AS pt ON pt.PartId    = e.ChildIdJOIN dbo.Products AS pr ON pr.ProductId = pt.ProductIdGROUP BY pr.Name, pr.PriceORDER BY Cost DESC;
Output
Name                 Qty  Price    Cost
-------------------  ---  -----  ------
Plywood sheet 18 mm    8  42.00  336.00
Timber 2x4 2.4 m      32   6.80  217.60
Wood screws (200)      3  11.90   35.70

The kit takes 32 lengths of timber, 8 plywood sheets and 3 boxes of screws, which cost 589.30 at today’s prices.

MAXRECURSION: the safety net

If the recursive part never stops producing rows, the query would run for ever. SQL Server stops it after 100 recursive steps by default:

Query.sql
WITH Numbers AS (    SELECT 1 AS n    UNION ALL    SELECT n + 1 FROM Numbers WHERE n < 200)SELECT MAX(n) AS Highest FROM Numbers;
Output
Msg 530, Level 16, State 1, Line 1
The statement terminated. The maximum recursion 100 has been exhausted before statement completion.

When you really need more steps, raise the limit for this query with OPTION (MAXRECURSION n), up to 32767, or 0 for no limit:

Query.sql
WITH Numbers AS (    SELECT 1 AS n    UNION ALL    SELECT n + 1 FROM Numbers WHERE n < 200)SELECT MAX(n) AS Highest FROM NumbersOPTION (MAXRECURSION 200);
Output
Highest
-------
    200
Quick check

In each recursive step, which rows does the recursive part join to?

Quick check

A recursive CTE fails with “Types don’t match between the anchor and the recursive part”. What usually fixes it?

Practice

Using the Staff table from this lesson, list everyone who works under Omar, directly or indirectly, with how many levels below him they are. Do not include Omar himself.

Show a hint

The anchor selects the people whose ManagerId is Omar’s StaffId (2), at level 1. The recursive part joins Staff to the CTE on s.ManagerId = previous.StaffId.

Show the solution
Solution.sql
WITH UnderOmar AS (    SELECT StaffId, Name, 1 AS LevelsBelow    FROM dbo.Staff    WHERE ManagerId = 2    UNION ALL    SELECT s.StaffId, s.Name, u.LevelsBelow + 1    FROM dbo.Staff AS s    JOIN UnderOmar AS u ON s.ManagerId = u.StaffId)SELECT Name, LevelsBelowFROM UnderOmarORDER BY LevelsBelow, Name;
Output
Name   LevelsBelow
-----  -----------
Priya            1
Tom              1
Kofi             2
Sam              2

Remember

  • A recursive CTE is an anchor query UNION ALL a recursive query that refers to the CTE.
  • Each step works on the rows produced by the previous step; it stops when a step produces none.
  • Generate numbers and dates, then LEFT JOIN data to fill in gaps.
  • Walk down a hierarchy by joining children to the previous level; walk up by joining managers.
  • A bill of materials multiplies quantities on the way down.
  • Anchor and recursive columns must have exactly the same types.
  • MAXRECURSION (default 100) stops runaway recursion; raise it with OPTION (MAXRECURSION n).
0 of 54 lessons completed