Day 4: Common table expressions
On this page
By the end of this lesson you can
- Declare, assign and use variables, and explain why a variable disappears after
GO. - Rewrite a nested derived-table query as a readable chain of CTEs.
- Reference one CTE several times in the same statement, including joining a CTE to itself.
- Write a recursive CTE with an anchor member and a recursive member, and trace how it builds its result level by level.
- Walk a hierarchy downwards (an organisation chart, a category tree) and upwards (a chain of command), computing level and path.
- Generate a series of dates with a recursive CTE, and control recursion depth with
OPTION (MAXRECURSION n). - Recognise and fix the common CTE errors: a missing semicolon, mismatched anchor/recursive types, and exceeded recursion.
A. Lesson overview
| Week | 1 — Advanced querying foundations |
| Level | Intermediate |
| Estimated time | 2 h 30 min essential + 30 min optional stretch |
| Prerequisites | Days 1–3, especially derived tables, correlated subqueries and LEFT JOIN |
| Syllabus topic | Common Table Expressions — simple CTEs, multiple CTEs, recursive CTEs |
| Foundations added today | Variables (DECLARE, SET) and batches (GO) |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Primer: variables and batches | 15 min |
| Part 2 — Simple and multiple CTEs | 25 min |
| Part 3 — Recursive CTEs | 35 min |
| Worked examples | 25 min |
| Guided lab | 25 min |
| Independent practice | 20 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. Ridgeway Outdoor’s management team wants three things before the planning meeting: an organisation chart with every reporting line, a sales report by top-level category even though products are attached to the lowest level of the category tree, and a daily December trading calendar that shows days with no sales. Each needs either a multi-step calculation or a walk through a hierarchy — the two jobs CTEs do best.
B. Theory explained clearly
Part 1 — Primer: variables and batches
B1. Variables
A variable holds a single value for the duration of a batch. Its name starts with @.
USE RetailLab;
GO
DECLARE @StartDate date = '2025-12-01'; -- declare and initialise
DECLARE @EndDate date; -- declare only: value is NULL
SET @EndDate = '2025-12-31'; -- assign
DECLARE @Orders int;
SELECT @Orders = COUNT(*) -- assign from a query
FROM Sales.SalesOrder
WHERE OrderDate >= @StartDate AND OrderDate <= @EndDate;
SELECT @StartDate AS StartDate, @EndDate AS EndDate, @Orders AS DecemberOrders; -- 27
- A declared but unassigned variable is NULL.
SETassigns one variable;SELECT @a = …, @b = …can assign several at once from a query.- If a
SELECT … @x = colquery returns several rows,@xends up with a value from one of them (typically the last processed), without error. Use an aggregate or make sure the query returns one row. - Variables make lesson queries easy to re-run with different parameters. On Day 17 you will see that stored procedure parameters behave like variables.
B2. Batches and GO
A batch is a group of statements sent to SQL Server together. In SSMS, GO on its own line separates batches. It is a client instruction, not T-SQL.
Variables live only within their batch:
DECLARE @Threshold int = 10;
GO
SELECT @Threshold; -- fails: Must declare the scalar variable "@Threshold".
Highlighting part of a script and pressing F5 also defines a batch: if you highlight a query that uses @StartDate without its DECLARE, you get the same error. Highlight the declarations too.
Part 2 — Simple and multiple CTEs
B3. What a CTE is
A common table expression is a named, temporary result set defined with WITH and used by the single statement that immediately follows.
WITH OrderTotals AS -- 1. name
(
SELECT o.OrderID, o.CustomerID, o.OrderDate,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.CustomerID, o.OrderDate -- 2. definition
)
SELECT CustomerID, COUNT(*) AS Orders, CAST(AVG(OrderNet) AS decimal(10,2)) AS AvgOrder
FROM OrderTotals -- 3. use
GROUP BY CustomerID
ORDER BY CustomerID;
A CTE is like a derived table that has been moved to the top and given a name. Benefits:
| Benefit | Why |
|---|---|
| Reads top to bottom | Each step is defined before it is used, instead of nested inside-out |
| Reusable within the statement | The same CTE can be referenced several times — a derived table must be repeated |
| Self-referencing | A CTE can refer to itself: recursion (Part 3) |
What a CTE is not:
- It is not stored. It exists only for the one statement. A second
SELECTafter the first cannot use it. - It is not a temporary table. SQL Server does not normally calculate it once and keep the result; each reference is expanded into the query, much as if you had written the derived table in that place. Referencing an expensive CTE three times may do the expensive work three times. On Day 15 you will meet temporary tables and table variables, which do store results.
B4. Syntax rules
-
Optional column list:
WITH OrderTotals (OrderID, CustomerID, OrderDate, OrderNet) AS ( … ). If present, it names the columns; otherwise every column in the innerSELECTneeds a name. -
Multiple CTEs: separate them with commas and use
WITHonly once. A later CTE may refer to an earlier one.WITH A AS ( … ), B AS ( SELECT … FROM A … ), C AS ( SELECT … FROM B JOIN A … ) SELECT … FROM C; -
Semicolon before WITH.
WITHhas other meanings in T-SQL, so the statement before a CTE must end with a semicolon. Otherwise you get Incorrect syntax near the keyword ‘with’. If this statement is a common table expression… the previous statement must be terminated with a semicolon. Some developers write;WITHdefensively; ending every statement with a semicolon is the cleaner habit. -
No
ORDER BYinside a CTE (unless withTOPorOFFSET), for the same reason as derived tables. Sort in the outer statement. -
The statement after the CTE can be
SELECT,INSERT,UPDATE,DELETEorMERGE(Day 15 uses this).
B5. Referencing a CTE twice: a self-join
Because a CTE has a name, you can join it to itself — for example, to put each month next to the previous month:
WITH Monthly AS
(
SELECT MONTH(o.OrderDate) AS MonthNo,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY MONTH(o.OrderDate)
)
SELECT cur.MonthNo,
CAST(cur.NetSales AS decimal(12,2)) AS NetSales,
CAST(prev.NetSales AS decimal(12,2)) AS PrevMonthNetSales
FROM Monthly AS cur
LEFT JOIN Monthly AS prev ON prev.MonthNo = cur.MonthNo - 1 -- January has no previous month
ORDER BY cur.MonthNo;
On Day 6 LAG() will do this more simply, but the self-join technique works for any relationship you can express in an ON clause.
Part 3 — Recursive CTEs
B6. Hierarchies stored as parent references
HR.Employee.ManagerID points to another employee; Inventory.Category.ParentCategoryID points to another category. This adjacency list design is simple to maintain, but answering “who is anywhere below Raj Patel?” needs one join per level — and you don’t know in advance how many levels there are. A recursive CTE repeats the join for you until there are no more levels.
B7. Anatomy of a recursive CTE
WITH Org AS
(
-- 1. Anchor member: the starting rows (level 0)
SELECT EmployeeID, ManagerID, FirstName, LastName,
0 AS Lvl
FROM HR.Employee
WHERE ManagerID IS NULL
UNION ALL
-- 2. Recursive member: joins the table to the rows produced by the previous step
SELECT e.EmployeeID, e.ManagerID, e.FirstName, e.LastName,
o.Lvl + 1
FROM HR.Employee AS e
JOIN Org AS o ON e.ManagerID = o.EmployeeID -- refers to the CTE itself
)
SELECT EmployeeID, ManagerID, FirstName, LastName, Lvl
FROM Org
ORDER BY Lvl, EmployeeID;
How SQL Server evaluates it (logically):
| Iteration | Input to the recursive member | Rows produced |
|---|---|---|
| Anchor | — | Eleanor Hughes (Lvl 0) |
| 1 | Eleanor | Her 4 direct reports: Raj, Fiona, Tom, Aisha (Lvl 1) |
| 2 | The 4 Lvl-1 rows | Liam, Chen, Gareth, Priya (Lvl 2) |
| 3 | The 4 Lvl-2 rows | 5 of Liam’s team + 2 of Gareth’s (Lvl 3) |
| 4 | The 7 Lvl-3 rows | nobody — recursion stops |
The final result is the UNION ALL of every iteration: 1 + 4 + 4 + 7 = 16 rows. Each recursive step sees only the rows produced by the previous step, not the whole result so far.
B8. Rules and restrictions
- Anchor and recursive members are combined with
UNION ALL. - Column data types must match exactly between anchor and recursive member. String concatenation is the usual trap: the anchor’s
FirstName + ' ' + LastNamemight benvarchar(101)while the recursive member’s longer path is a different length. The error is Types don’t match between the anchor and the recursive part in column “Path” of recursive query “Org”. Fix it by casting both sides to the same type, e.g.CAST(… AS nvarchar(400)). - The recursive member cannot use
GROUP BY,HAVING, aggregate functions,DISTINCT,TOP, outer joins or subqueries. Do aggregation in the outer query. - Recursion limit. By default SQL Server stops after 100 recursion levels with error 530: The statement terminated. The maximum recursion 100 has been exhausted before statement completion. Add
OPTION (MAXRECURSION n)to the outer statement: any value from 0 to 32,767;0means no limit. Use 0 only when the data is guaranteed to end — a cycle in the data (an employee who is, indirectly, their own manager) would otherwise run forever.
B9. Carrying values down the hierarchy: level, path and top ancestor
Columns computed in the anchor can be passed down and extended:
| Column | Anchor | Recursive member |
|---|---|---|
| Level | 0 |
parent.Lvl + 1 |
| Path | CAST(Name AS nvarchar(400)) |
CAST(parent.Path + N' > ' + Name AS nvarchar(400)) |
| Top ancestor | CategoryID AS TopCategoryID |
parent.TopCategoryID (unchanged) |
Sorting by Path lists a tree in “depth-first” order — each node immediately followed by its descendants — which is how people expect to read an organisation chart.
B10. Walking upwards
To list an employee’s chain of command, anchor on the employee and join to the manager at each step:
-- anchor: WHERE EmployeeID = @EmployeeID
-- recursive: JOIN Chain AS c ON e.EmployeeID = c.ManagerID
The recursion ends when it reaches the person whose ManagerID is NULL: the join finds no further manager.
B11. Generating series
A recursive CTE can also generate rows from nothing — useful for a list of dates:
DECLARE @StartDate date = '2025-12-01', @EndDate date = '2025-12-31';
WITH Dates AS
(
SELECT @StartDate AS TheDate
UNION ALL
SELECT DATEADD(DAY, 1, TheDate) FROM Dates WHERE TheDate < @EndDate
)
SELECT TheDate FROM Dates
OPTION (MAXRECURSION 400); -- one recursion level per day after the first
DATEADD(DAY, 1, d) adds one day (Day 12 covers date functions). For long series, recursion is not the fastest method: SQL Server 2022 and later (compatibility level 160+) also provide the GENERATE_SERIES function, and Day 12 builds a permanent calendar table. Recursive generation is perfectly adequate for a few hundred rows.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | HR.Employee, Inventory.Category, Inventory.Product, Sales.SalesOrder, Sales.SalesOrderLine |
| Hierarchies | HR.Employee.ManagerID → HR.Employee.EmployeeID (4 levels); Inventory.Category.ParentCategoryID → Inventory.Category.CategoryID (3 levels) |
| Permissions | SELECT on these tables |
| Objects created today | None — Day 4 is read-only |
Category tree (products are attached to the lowest level only):
Accessories (9) Camping (1) Clothing (5)
├─ Backpacks (12) ├─ Cooking (4) ├─ Base Layers (8)
├─ Hydration (13) *empty* ├─ Sleeping (3) ├─ Footwear (7)
├─ Lighting (11) └─ Tents (2) └─ Jackets (6)
└─ Navigation (10) ├─ Insulated Jackets (15)
└─ Waterproof Jackets (14)
Preparation. None if RetailLab is unchanged. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive) or scripts 00 → 03.
Validation. Expected: 16, 1, 15, 3.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM HR.Employee) AS Employees,
(SELECT COUNT(*) FROM HR.Employee WHERE ManagerID IS NULL) AS TopOfHierarchy,
(SELECT COUNT(*) FROM Inventory.Category) AS Categories,
(SELECT COUNT(*) FROM Inventory.Category WHERE ParentCategoryID IS NULL) AS TopCategories;
D. Worked examples
Worked example 1 — Day 3’s “large orders” query, rewritten with one CTE
WITH OrderTotals AS
(
SELECT o.OrderID, o.CustomerID, o.OrderDate,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.OrderID, o.CustomerID, o.OrderDate
),
CustomerAvg AS -- second CTE built on the first
(
SELECT CustomerID, AVG(OrderNet) AS AvgOrder
FROM OrderTotals
GROUP BY CustomerID
)
SELECT t.OrderID,
t.CustomerID,
t.OrderDate,
CAST(t.OrderNet AS decimal(10,2)) AS OrderNet,
CAST(a.AvgOrder AS decimal(10,2)) AS CustomerAvgOrder
FROM OrderTotals AS t
JOIN CustomerAvg AS a ON a.CustomerID = t.CustomerID
WHERE t.OrderDate >= '2025-01-01'
AND t.OrderDate < '2026-01-01'
AND t.OrderNet > 2 * a.AvgOrder
ORDER BY t.OrderDate, t.OrderID;
Expected result: the same 19 rows as Day 3 Task 3, from order 1177 to order 1376. The per-order calculation is now written once.
Worked example 2 — Month on month with a CTE self-join
WITH Monthly AS
(
SELECT MONTH(o.OrderDate) AS MonthNo,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY MONTH(o.OrderDate)
)
SELECT cur.MonthNo,
CAST(cur.NetSales AS decimal(12,2)) AS NetSales,
CAST(prev.NetSales AS decimal(12,2)) AS PrevMonth,
CAST(cur.NetSales - prev.NetSales AS decimal(12,2)) AS Change,
CAST(100.0 * (cur.NetSales - prev.NetSales)
/ NULLIF(prev.NetSales, 0) AS decimal(6,1)) AS ChangePct
FROM Monthly AS cur
LEFT JOIN Monthly AS prev ON prev.MonthNo = cur.MonthNo - 1
ORDER BY cur.MonthNo;
Expected result: 12 rows. January has NULL in the last three columns. Selected rows:
| MonthNo | NetSales | PrevMonth | Change | ChangePct |
|---|---|---|---|---|
| 1 | 3749.57 | NULL | NULL | NULL |
| 2 | 3891.60 | 3749.57 | 142.03 | 3.8 |
| 8 | 2419.30 | 4536.58 | -2117.28 | -46.7 |
| 9 | 5755.09 | 2419.30 | 3335.79 | 137.9 |
| 12 | 9718.65 | 4088.04 | 5630.61 | 137.7 |
Business meaning: the August dip and September rebound are mostly the warehouse-migration week, not a change in demand. A percentage change on its own can mislead.
Worked example 3 — The organisation chart (recursive, top-down)
WITH Org AS
(
SELECT EmployeeID, ManagerID, JobTitle,
CAST(CONCAT_WS(N' ', FirstName, LastName) AS nvarchar(400)) AS EmployeeName,
0 AS Lvl,
CAST(CONCAT_WS(N' ', FirstName, LastName) AS nvarchar(400)) AS OrgPath
FROM HR.Employee
WHERE ManagerID IS NULL
UNION ALL
SELECT e.EmployeeID, e.ManagerID, e.JobTitle,
CAST(CONCAT_WS(N' ', e.FirstName, e.LastName) AS nvarchar(400)),
o.Lvl + 1,
CAST(o.OrgPath + N' > ' + CONCAT_WS(N' ', e.FirstName, e.LastName) AS nvarchar(400))
FROM HR.Employee AS e
JOIN Org AS o ON e.ManagerID = o.EmployeeID
)
SELECT EmployeeID,
REPLICATE(N' ', Lvl) + EmployeeName AS IndentedName, -- 4 spaces per level
JobTitle,
Lvl,
OrgPath
FROM Org
ORDER BY OrgPath;
Expected result: 16 rows in depth-first order.
| EmployeeID | IndentedName | JobTitle | Lvl |
|---|---|---|---|
| 1 | Eleanor Hughes | Managing Director | 0 |
| 5 | Aisha Khan | IT Manager | 1 |
| 3 | Fiona Campbell | Head of Operations | 1 |
| 11 | Gareth Price | Warehouse Supervisor | 2 |
| 12 | Hannah Scott | Warehouse Operative | 3 |
| 13 | Marek Nowak | Warehouse Operative | 3 |
| 2 | Raj Patel | Head of Sales | 1 |
| 10 | Chen Wei | Sales Associate | 2 |
| 6 | Liam O’Connor | Sales Team Lead | 2 |
| 8 | Daniel Okafor | Sales Associate | 3 |
| 16 | James Reid | Sales Associate | 3 |
| 9 | Megan Lewis | Sales Associate | 3 |
| 15 | Oliver Grant | Sales Associate | 3 |
| 7 | Sophie Turner | Sales Associate | 3 |
| 4 | Tom Baxter | Finance Manager | 1 |
| 14 | Priya Sharma | Accounts Assistant | 2 |
(In SSMS the indentation shows as spaces; the table above uses visible padding.) Remove the CASTs and run it again to see the Types don’t match error from B8.
Note: James Reid left in June 2025 but still appears; the hierarchy table records reporting lines, not employment status. Add WHERE TerminationDate IS NULL to both members if leavers should be hidden — filtering only the outer query would also hide any current employees who reported through a leaver.
Worked example 4 — Category paths
WITH CategoryTree AS
(
SELECT CategoryID, ParentCategoryID, CategoryName,
1 AS Lvl,
CAST(CategoryName AS nvarchar(400)) AS CategoryPath
FROM Inventory.Category
WHERE ParentCategoryID IS NULL
UNION ALL
SELECT c.CategoryID, c.ParentCategoryID, c.CategoryName,
t.Lvl + 1,
CAST(t.CategoryPath + N' > ' + c.CategoryName AS nvarchar(400))
FROM Inventory.Category AS c
JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID
)
SELECT CategoryID, Lvl, CategoryPath
FROM CategoryTree
ORDER BY CategoryPath;
Expected result: 15 rows, from Accessories and Accessories > Backpacks to Clothing > Jackets > Waterproof Jackets. Three rows have Lvl = 1, ten have Lvl = 2 and two have Lvl = 3.
Worked example 5 — Days in August 2025 with no orders (date series + LEFT JOIN)
DECLARE @StartDate date = '2025-08-01',
@EndDate date = '2025-08-31';
WITH Dates AS
(
SELECT @StartDate AS TheDate
UNION ALL
SELECT DATEADD(DAY, 1, TheDate) FROM Dates WHERE TheDate < @EndDate
)
SELECT d.TheDate
FROM Dates AS d
LEFT JOIN Sales.SalesOrder AS o ON o.OrderDate = d.TheDate
WHERE o.OrderID IS NULL -- anti-join from Day 3
ORDER BY d.TheDate
OPTION (MAXRECURSION 100);
Expected result: 21 dates, including the unbroken run 8–18 August. Ridgeway is a small business, so many quiet days are normal; the warehouse-migration week (11–17 August) sits inside a longer quiet spell. On Day 29 you will find runs like this automatically (“gaps and islands”).
Highlight the DECLARE together with the query when you run it — otherwise you get Must declare the scalar variable “@StartDate”.
Worked example 6 — Hitting the recursion limit
WITH Numbers AS
(
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM Numbers WHERE n < 150
)
SELECT MAX(n) AS MaxN FROM Numbers; -- error 530 at level 100
WITH Numbers AS
(
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM Numbers WHERE n < 150
)
SELECT MAX(n) AS MaxN FROM Numbers
OPTION (MAXRECURSION 200); -- 150
Expected result: the first statement fails with error 530 (The maximum recursion 100 has been exhausted…); the second returns 150. Note that both statements are complete and end with semicolons, so they can be run together.
E. Guided practical lab — 2025 sales by top-level category
Products belong to the lowest-level categories (Waterproof Jackets, Tents…), but management wants 2025 net sales by the three top-level categories: Accessories, Camping and Clothing.
Step 1 — Build the category tree with each node’s top-level ancestor
-
Do: Write a recursive CTE that carries the top-level category’s ID and name down to every descendant.
-
Run:
USE RetailLab; GO WITH CategoryTree AS ( SELECT CategoryID, CategoryName, CategoryID AS TopCategoryID, CategoryName AS TopCategoryName, 1 AS Lvl FROM Inventory.Category WHERE ParentCategoryID IS NULL UNION ALL SELECT c.CategoryID, c.CategoryName, t.TopCategoryID, -- passed down unchanged t.TopCategoryName, t.Lvl + 1 FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID ) SELECT CategoryID, CategoryName, TopCategoryName, Lvl FROM CategoryTree ORDER BY TopCategoryName, Lvl, CategoryName; -
Observe: 15 rows. Waterproof Jackets shows
TopCategoryName = ClothingandLvl = 3. -
Why it matters: This is a mapping from every category to its top level — it does not matter how deep a category sits.
-
Confirm: 15 rows: 5 with
TopCategoryName = Accessories, 4 with Camping and 6 with Clothing (each top-level category counts as its own descendant).(No type mismatch here:
CategoryNameis passed through unchanged in both members, so its type is identical.)
Step 2 — Add a CTE for 2025 sales per product
- Do: After the recursive CTE, add a comma and a second CTE.
- Run: append to the
WITHlist (replace the finalSELECTfor now with a check on the new CTE):, ProductSales AS ( SELECT l.ProductID, COUNT(DISTINCT o.OrderID) AS Orders, SUM(l.Quantity) AS Units, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' GROUP BY l.ProductID ) SELECT COUNT(*) AS ProductsSold, SUM(Units) AS Units, CAST(SUM(NetSales) AS decimal(12,2)) AS NetSales FROM ProductSales; - Observe:
28,538,63741.94. - Why it matters: The control total matches Day 2’s 2025 figure, so this CTE is correct before you build on it.
- Confirm: 63741.94.
Step 3 — Roll sales up to the top-level category
-
Do: Replace the final
SELECTwith one that joins products to the tree. -
Run: the complete statement:
WITH CategoryTree AS ( SELECT CategoryID, CategoryName, CategoryID AS TopCategoryID, CategoryName AS TopCategoryName, 1 AS Lvl FROM Inventory.Category WHERE ParentCategoryID IS NULL UNION ALL SELECT c.CategoryID, c.CategoryName, t.TopCategoryID, t.TopCategoryName, t.Lvl + 1 FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID ), ProductSales AS ( SELECT l.ProductID, SUM(l.Quantity) AS Units, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' GROUP BY l.ProductID ) SELECT t.TopCategoryName, SUM(ps.Units) AS Units, CAST(SUM(ps.NetSales) AS decimal(12,2)) AS NetSales FROM ProductSales AS ps JOIN Inventory.Product AS p ON p.ProductID = ps.ProductID JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID GROUP BY t.TopCategoryName ORDER BY NetSales DESC, t.TopCategoryName; -
Observe:
TopCategoryName Units NetSales Camping 185 27572.05 Clothing 211 24438.80 Accessories 142 11731.09 -
Why it matters: The query does not hard-code any category IDs or levels. If Merchandising adds a fourth level tomorrow, it still works.
-
Confirm: 3 rows; the three
NetSalesvalues add up to 63741.94.
Step 4 — Add an automatic reconciliation check
- Do: Add a third CTE holding the result, and return it together with a check row that compares the total with the control total.
- Run: replace the final
SELECTof step 3 with:
(Delete the final, ByTop AS ( SELECT t.TopCategoryName, SUM(ps.NetSales) AS NetSales FROM ProductSales AS ps JOIN Inventory.Product AS p ON p.ProductID = ps.ProductID JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID GROUP BY t.TopCategoryName ) SELECT CAST((SELECT SUM(NetSales) FROM ByTop) AS decimal(12,2)) AS CategoryTotal, CAST((SELECT SUM(NetSales) FROM ProductSales) AS decimal(12,2)) AS ProductTotal, CASE WHEN (SELECT SUM(NetSales) FROM ByTop) = (SELECT SUM(NetSales) FROM ProductSales) THEN 'Reconciled' ELSE 'MISMATCH' END AS CheckResult;SELECTfrom step 3 first: aWITHlist is followed by exactly one statement. Start the, ByTop ASdirectly after the closing parenthesis ofProductSales.) - Observe:
63741.94,63741.94,Reconciled. - Why it matters: If a product ever sat in a category that the tree could not reach — for example, a category whose parent was deleted — its sales would vanish from the category report. A built-in reconciliation catches that immediately.
- Confirm:
Reconciled.
F. Independent practice
Task 1 — Best sellers of 2025 (simple CTE used twice)
Using one CTE that returns units sold per product in 2025 (non-cancelled orders), return ProductID, ProductCode, ProductName and Units for products whose units are above the average units per product (averaged over the products that sold in 2025). Sort by units descending, then ProductID.
Success criteria: 14 rows, from product 9 (COK-003, 38 units) to product 27 (BPK-002, 20 units). 28 products sold in 2025, averaging about 19.21 units.
Hint
Units is an int, so AVG(Units) returns 19, not 19.21. Here that happens not to change the answer (product 21 sold exactly 19 units, and 19 is not greater than either value) — but make it decimal anyway.
Task 2 — Chain of command (recursive, upwards)
Using a variable @EmployeeID int = 13, return the chain of command from that employee up to the Managing Director: Step (0 for the employee), EmployeeID, EmployeeName and JobTitle, sorted by Step.
Success criteria: 4 rows — Marek Nowak (0), Gareth Price (1), Fiona Campbell (2), Eleanor Hughes (3). With @EmployeeID = 1 you get 1 row.
Hint
Anchor on the chosen employee. In the recursive member, join HR.Employee AS e where e.EmployeeID = c.ManagerID.
Task 3 — Everyone below each manager (recursive + aggregation)
For every employee who has at least one report (direct or indirect), return ManagerID, ManagerName, TeamSize (number of current employees anywhere below them) and TeamSalary (their total salary). Sort by TeamSize descending, then ManagerID.
Success criteria: 6 rows. Eleanor Hughes: 14 / 640000.00; Raj Patel: 6 / 213000.00; Liam O’Connor: 4 / 129500.00; Fiona Campbell: 3 / 91000.00; Gareth Price: 2 / 53000.00; Tom Baxter: 1 / 29000.00.
Hints
- A recursive CTE that produces one row per (manager, descendant) pair: anchor each employee as their own “root” (
RootID = EmployeeID, EmployeeID), then follow the reports down while keepingRootID. - Exclude the root itself and leavers in the outer query, then
GROUP BY RootID. - Aggregation is not allowed inside the recursive member — do it outside.
Task 4 — December trading calendar (date series + aggregation)
Return one row for every day of December 2025 with TheDate, Orders and NetSales (non-cancelled orders), showing 0 and 0.00 on days without sales. Use variables for the start and end dates.
Success criteria: 31 rows; 10 days have no orders; the totals of the two columns are 27 and 9718.65. 13 December (1 order, 1153.30) is the best single day.
Hints
- Aggregate orders per day in one CTE, generate dates in another, then
LEFT JOINthe dates to the daily totals. - Use
COALESCE(…, 0)for days with no matching row. - Joining dates directly to order lines and then counting works too, but you must count
DISTINCTorders and put the cancelled-order filter in theONclause.
Stretch challenge — Category tree with rolled-up figures
Produce the full category tree (15 rows) sorted by path, with an indented name and, for each category, the number of products in that category and all its descendants, plus 2025 net sales for that subtree. Empty categories show 0 and 0.00.
Success criteria: Clothing shows 11 products and 24438.80; Jackets 5 products and 15090.15; Hydration 0 and 0.00; the three top-level rows add up to 30 products and 63741.94.
Hint
Generate (RootCategoryID, DescendantCategoryID) pairs recursively — every category is its own descendant at depth 0. Then join descendants to products and sales and group by the root. Join the tree CTE from worked example 4 for the path.
G. Solutions and explanations
Show solution 1
WITH Units2025 AS
(
SELECT l.ProductID, SUM(l.Quantity) AS Units
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY l.ProductID
)
SELECT u.ProductID, p.ProductCode, p.ProductName, u.Units
FROM Units2025 AS u
JOIN Inventory.Product AS p ON p.ProductID = u.ProductID
WHERE u.Units > (SELECT AVG(CAST(Units AS decimal(10,2))) FROM Units2025)
ORDER BY u.Units DESC, u.ProductID;
Result: 14 rows: 9 (38), 15 (30), 10 (27), 2 (26), 26 (25), 8 (24), 3 (22), 12 (22), 28 (22), 20 (21), 1 (20), 13 (20), 18 (20), 27 (20).
Reasoning. The CTE is referenced twice — in FROM and in the subquery. The average (538 ÷ 28 = 19.214…) covers only products that sold in 2025. If the business wanted the average across all 30 products (including two with no 2025 sales), you would need a LEFT JOIN from Inventory.Product and COALESCE(Units, 0), which lowers the average to 17.93 and returns 17 products: products 14 and 23 (18 units) and 21 (19 units) join the list. The definition of “average” changes the answer, so agree it with the business.
Show solution 2
DECLARE @EmployeeID int = 13;
WITH Chain AS
(
SELECT 0 AS Step, EmployeeID, ManagerID, JobTitle,
CAST(CONCAT_WS(N' ', FirstName, LastName) AS nvarchar(101)) AS EmployeeName
FROM HR.Employee
WHERE EmployeeID = @EmployeeID
UNION ALL
SELECT c.Step + 1, e.EmployeeID, e.ManagerID, e.JobTitle,
CAST(CONCAT_WS(N' ', e.FirstName, e.LastName) AS nvarchar(101))
FROM HR.Employee AS e
JOIN Chain AS c ON e.EmployeeID = c.ManagerID -- move UP one level
)
SELECT Step, EmployeeID, EmployeeName, JobTitle
FROM Chain
ORDER BY Step;
| Step | EmployeeID | EmployeeName | JobTitle |
|---|---|---|---|
| 0 | 13 | Marek Nowak | Warehouse Operative |
| 1 | 11 | Gareth Price | Warehouse Supervisor |
| 2 | 3 | Fiona Campbell | Head of Operations |
| 3 | 1 | Eleanor Hughes | Managing Director |
Reasoning. The only difference from the top-down query is the direction of the join. The CAST keeps the name type identical in both members; without it, CONCAT_WS may still return the same type on both sides, but being explicit prevents surprises when the expressions differ. If @EmployeeID does not exist, the anchor returns no rows and so does the whole query.
Show solution 3
WITH Descendants AS
(
SELECT EmployeeID AS RootID, EmployeeID -- every employee is the root of their own subtree
FROM HR.Employee
UNION ALL
SELECT d.RootID, e.EmployeeID
FROM HR.Employee AS e
JOIN Descendants AS d ON e.ManagerID = d.EmployeeID
)
SELECT d.RootID AS ManagerID,
CONCAT_WS(N' ', m.FirstName, m.LastName) AS ManagerName,
COUNT(*) AS TeamSize,
SUM(e.Salary) AS TeamSalary
FROM Descendants AS d
JOIN HR.Employee AS e ON e.EmployeeID = d.EmployeeID -- the descendant
JOIN HR.Employee AS m ON m.EmployeeID = d.RootID -- the manager
WHERE d.EmployeeID <> d.RootID -- exclude the manager themself
AND e.TerminationDate IS NULL -- current employees only
GROUP BY d.RootID, m.FirstName, m.LastName
ORDER BY TeamSize DESC, ManagerID;
| ManagerID | ManagerName | TeamSize | TeamSalary |
|---|---|---|---|
| 1 | Eleanor Hughes | 14 | 640000.00 |
| 2 | Raj Patel | 6 | 213000.00 |
| 6 | Liam O’Connor | 4 | 129500.00 |
| 3 | Fiona Campbell | 3 | 91000.00 |
| 11 | Gareth Price | 2 | 53000.00 |
| 4 | Tom Baxter | 1 | 29000.00 |
Reasoning. The CTE produces 49 (root, descendant) pairs, including the 16 self-pairs. Aisha Khan does not appear because she has no reports. The leaver filter is applied to the descendant in the outer query; James Reid left, but nobody reported through him, so filtering inside the recursion would give the same result here. m.FirstName, m.LastName are in GROUP BY because they appear in the SELECT list; they are functionally determined by RootID, so they do not change the groups.
Show solution 4
DECLARE @StartDate date = '2025-12-01',
@EndDate date = '2025-12-31';
WITH Dates AS
(
SELECT @StartDate AS TheDate
UNION ALL
SELECT DATEADD(DAY, 1, TheDate) FROM Dates WHERE TheDate < @EndDate
),
DailySales AS
(
SELECT o.OrderDate,
COUNT(DISTINCT o.OrderID) AS Orders,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate BETWEEN @StartDate AND @EndDate
GROUP BY o.OrderDate
)
SELECT d.TheDate,
COALESCE(s.Orders, 0) AS Orders,
CAST(COALESCE(s.NetSales, 0) AS decimal(12,2)) AS NetSales
FROM Dates AS d
LEFT JOIN DailySales AS s ON s.OrderDate = d.TheDate
ORDER BY d.TheDate
OPTION (MAXRECURSION 400);
Result: 31 rows; zero-order days are 1, 2, 4, 6, 9, 10, 12, 14, 15 and 18 December. Totals: 27 orders, 9718.65.
Reasoning. Aggregating first and then joining keeps the grain simple: one row per date on each side. BETWEEN is safe on a date column. MAXRECURSION 400 comfortably covers a month (30 levels) and makes the query reusable for up to about a year.
Show the stretch solution
WITH CategoryTree AS
(
SELECT CategoryID, CategoryName, 1 AS Lvl, CAST(CategoryName AS nvarchar(400)) AS CategoryPath
FROM Inventory.Category
WHERE ParentCategoryID IS NULL
UNION ALL
SELECT c.CategoryID, c.CategoryName, t.Lvl + 1,
CAST(t.CategoryPath + N' > ' + c.CategoryName AS nvarchar(400))
FROM Inventory.Category AS c
JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID
),
SubTree AS -- (root, descendant) pairs, including self
(
SELECT CategoryID AS RootID, CategoryID AS DescendantID
FROM Inventory.Category
UNION ALL
SELECT s.RootID, c.CategoryID
FROM Inventory.Category AS c
JOIN SubTree AS s ON c.ParentCategoryID = s.DescendantID
),
ProductSales AS
(
SELECT l.ProductID, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY l.ProductID
),
RolledUp AS
(
SELECT s.RootID,
COUNT(p.ProductID) AS Products, -- counts only matched products
COALESCE(SUM(ps.NetSales), 0) AS NetSales
FROM SubTree AS s
LEFT JOIN Inventory.Product AS p ON p.CategoryID = s.DescendantID
LEFT JOIN ProductSales AS ps ON ps.ProductID = p.ProductID
GROUP BY s.RootID
)
SELECT t.CategoryID,
REPLICATE(N' ', t.Lvl - 1) + t.CategoryName AS Category,
r.Products,
CAST(r.NetSales AS decimal(12,2)) AS NetSales2025
FROM CategoryTree AS t
JOIN RolledUp AS r ON r.RootID = t.CategoryID
ORDER BY t.CategoryPath;
| CategoryID | Category | Products | NetSales2025 |
|---|---|---|---|
| 9 | Accessories | 9 | 11731.09 |
| 12 | Backpacks | 3 | 5991.55 |
| 13 | Hydration | 0 | 0.00 |
| 11 | Lighting | 2 | 993.10 |
| 10 | Navigation | 4 | 4746.44 |
| 1 | Camping | 10 | 27572.05 |
| 4 | Cooking | 3 | 2378.75 |
| 3 | Sleeping | 3 | 7611.85 |
| 2 | Tents | 4 | 17581.45 |
| 5 | Clothing | 11 | 24438.80 |
| 8 | Base Layers | 3 | 2577.00 |
| 7 | Footwear | 3 | 6771.65 |
| 6 | Jackets | 5 | 15090.15 |
| 15 | Insulated Jackets | 3 | 8068.80 |
| 14 | Waterproof Jackets | 2 | 7021.35 |
(Indentation shown without spaces here.) Reasoning. Joining SubTree to products gives one row per (root, product); joining ProductSales keeps that grain because ProductSales has one row per product — so COUNT(p.ProductID) counts products correctly. Had you joined order lines instead, the count would be inflated: always check the grain before counting. Navigation shows 4 products and 4746.44: the Satellite Messenger has no 2025 sales, and the discontinued bell tent still counts as a Tents product.
H. Knowledge check
-
Understanding. In a recursive CTE, what does the recursive member see on each iteration: the entire result so far, or only the rows from the previous iteration?
-
Debugging. This fails with Incorrect syntax near the keyword ‘with’. Why?
SELECT COUNT(*) FROM Sales.Customer WITH c AS (SELECT * FROM Sales.Customer) SELECT * FROM c; -
Debugging. A recursive org-chart query fails with Types don’t match between the anchor and the recursive part in column “OrgPath”. Explain and fix.
-
Query interpretation. You run a CTE, then a second
SELECT … FROM thatCteon the next line, highlighted together. The second statement fails with Invalid object name. Why? -
Understanding. Why should you be careful with
OPTION (MAXRECURSION 0)on a hierarchy that users can edit?
Show the answers
-
Only the rows produced by the previous iteration (the anchor, on the first iteration). The final result is the
UNION ALLof all iterations. -
The first statement is not terminated with a semicolon, so SQL Server reads
WITHas part of it. EndSELECT COUNT(*) FROM Sales.Customer;with a semicolon. -
The anchor’s path expression and the recursive member’s (longer) path expression have different data types or lengths. Cast both to the same type:
CAST(… AS nvarchar(400))in both members. -
A CTE exists only for the single statement immediately following it. The second statement is a separate statement; repeat the
WITHor store the result (temporary table, Day 15). -
0removes the limit. If someone creates a cycle — for example A manages B, B manages C and C is set as A’s manager — the recursion never ends naturally. A finite limit turns that into an error you can investigate instead of a runaway query. (Day 19 shows how a trigger or constraint can prevent cycles.)
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Incorrect syntax near the keyword ‘with’… | The previous statement lacks a semicolon | End every statement with ; |
| Invalid object name ‘OrderTotals’. | Using a CTE in a second statement, or a typo | A CTE serves one statement only |
| Types don’t match between the anchor and the recursive part… | Different types/lengths in a column of the two members | CAST both to the same type |
| The statement terminated. The maximum recursion 100 has been exhausted… (530) | More than 100 levels | OPTION (MAXRECURSION n) on the outer statement; check for cycles |
| Must declare the scalar variable “@X”. | Variable declared in another batch, or the DECLARE was not highlighted |
Run the DECLARE in the same batch |
| GROUP BY, HAVING, or aggregate functions are not allowed in the recursive part… | Aggregation in the recursive member | Aggregate in the outer query |
| Outer join is not allowed in the recursive part… | LEFT JOIN in the recursive member |
Use an inner join; keep unmatched rows with an outer join outside the recursion |
| Hierarchy rows missing | A filter in only one member, or a broken parent reference | Apply filters to both members, or outside; check for parents that do not exist |
Query runs forever with MAXRECURSION 0 |
A cycle in the data | Cancel (Alt + Break or the red stop button), set a limit, find the cycle |
Cleanup. Day 4 is read-only; nothing to clean up. Keep RetailLab unchanged for Day 5.
J. Completion checklist, recap and references
Completion checklist
- Worked example 1 reproduces Day 3’s 19 rows with the per-order logic written once.
- Your org chart (worked example 3) returns 16 rows and you have seen the Types don’t match error.
- The guided lab returns Camping 27572.05, Clothing 24438.80, Accessories 11731.09 and Reconciled.
- Tasks 1–4 match the success criteria (14, 4, 6 and 31 rows).
- You can explain what each recursion iteration receives as input.
Recap
- Variables (
@name) hold one value for a batch;GOends the batch. - A CTE names a query for the next statement only. It is expanded, not stored.
- Multiple CTEs read top to bottom; a later CTE may use an earlier one; a CTE may be referenced several times, including in a self-join.
- A recursive CTE = anchor
UNION ALLrecursive member. Each iteration sees only the previous iteration’s rows. Types must match exactly. - Carry level, path and top ancestor down the hierarchy; walk upwards by reversing the join.
- The default recursion limit is 100; set
OPTION (MAXRECURSION n)on the outer statement, and beware of cycles with0.
Next: Day 5 — Window functions part 1: ranking with ROW_NUMBER, RANK, DENSE_RANK and NTILE — including the clean way to solve “largest order per customer”.
References (Microsoft Learn)
- WITH common_table_expression (Transact-SQL) — syntax, recursion rules and restrictions
- Recursive queries using common table expressions
- Query hints (Transact-SQL) —
MAXRECURSION - DECLARE @local_variable (Transact-SQL)
- SET @local_variable (Transact-SQL)
- Batches of SQL statements
- GENERATE_SERIES (Transact-SQL)