Track contentsDay 4 of 30

Day 4: Common table expressions

Intermediate2 hrs 30 minWorks in: SQL Server 2025, SSMS 22
On this page
  1. A. Lesson overview
  2. B. Theory explained clearly
  3. C. Dataset and starting state
  4. D. Worked examples
  5. E. Guided practical lab — 2025 sales by top-level category
  6. F. Independent practice
  7. G. Solutions and explanations
  8. H. Knowledge check
  9. I. Troubleshooting and cleanup
  10. J. Completion checklist, recap and references

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.
  • SET assigns one variable; SELECT @a = …, @b = … can assign several at once from a query.
  • If a SELECT … @x = col query returns several rows, @x ends 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 SELECT after 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 inner SELECT needs a name.

  • Multiple CTEs: separate them with commas and use WITH only 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. WITH has 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 ;WITH defensively; ending every statement with a semicolon is the cleaner habit.

  • No ORDER BY inside a CTE (unless with TOP or OFFSET), for the same reason as derived tables. Sort in the outer statement.

  • The statement after the CTE can be SELECT, INSERT, UPDATE, DELETE or MERGE (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 + ' ' + LastName might be nvarchar(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; 0 means 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

  1. Do: Write a recursive CTE that carries the top-level category’s ID and name down to every descendant.

  2. 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;
  3. Observe: 15 rows. Waterproof Jackets shows TopCategoryName = Clothing and Lvl = 3.

  4. Why it matters: This is a mapping from every category to its top level — it does not matter how deep a category sits.

  5. 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: CategoryName is passed through unchanged in both members, so its type is identical.)

Step 2 — Add a CTE for 2025 sales per product

  1. Do: After the recursive CTE, add a comma and a second CTE.
  2. Run: append to the WITH list (replace the final SELECT for 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;
  3. Observe: 28, 538, 63741.94.
  4. Why it matters: The control total matches Day 2’s 2025 figure, so this CTE is correct before you build on it.
  5. Confirm: 63741.94.

Step 3 — Roll sales up to the top-level category

  1. Do: Replace the final SELECT with one that joins products to the tree.

  2. 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;
  3. Observe:

    TopCategoryName Units NetSales
    Camping 185 27572.05
    Clothing 211 24438.80
    Accessories 142 11731.09
  4. Why it matters: The query does not hard-code any category IDs or levels. If Merchandising adds a fourth level tomorrow, it still works.

  5. Confirm: 3 rows; the three NetSales values add up to 63741.94.

Step 4 — Add an automatic reconciliation check

  1. Do: Add a third CTE holding the result, and return it together with a check row that compares the total with the control total.
  2. Run: replace the final SELECT of step 3 with:
    , 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;
    (Delete the final SELECT from step 3 first: a WITH list is followed by exactly one statement. Start the , ByTop AS directly after the closing parenthesis of ProductSales.)
  3. Observe: 63741.94, 63741.94, Reconciled.
  4. 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.
  5. 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 keeping RootID.
  • 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 JOIN the 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 DISTINCT orders and put the cancelled-order filter in the ON clause.

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

  1. 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?

  2. 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;
  3. 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.

  4. Query interpretation. You run a CTE, then a second SELECT … FROM thatCte on the next line, highlighted together. The second statement fails with Invalid object name. Why?

  5. Understanding. Why should you be careful with OPTION (MAXRECURSION 0) on a hierarchy that users can edit?

Show the answers
  1. Only the rows produced by the previous iteration (the anchor, on the first iteration). The final result is the UNION ALL of all iterations.

  2. The first statement is not terminated with a semicolon, so SQL Server reads WITH as part of it. End SELECT COUNT(*) FROM Sales.Customer; with a semicolon.

  3. 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.

  4. A CTE exists only for the single statement immediately following it. The second statement is a separate statement; repeat the WITH or store the result (temporary table, Day 15).

  5. 0 removes 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; GO ends 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 ALL recursive 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 with 0.

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)