Day 7: Week 1 review & integration
On this page
By the end of this lesson you can
- List the logical processing order of a
SELECTstatement fromFROMtoOFFSET … FETCH, and use it to predict which aliases and functions each clause can see. - Return the first n rows of an ordered result with
TOP (n), include tied rows withWITH TIES, and explain whyTOPwithoutORDER BYis arbitrary. - Page through results with
OFFSET … FETCHusing a deterministic order. - Decide, for a given business question, whether to use a subquery, derived table, CTE,
GROUP BY, ranking function or framed aggregate. - Build a multi-step analytical query that combines CTEs, aggregation, conditional aggregation, window functions and
CASE, and validate it against a control total.
A. Lesson overview
| Week | 1 — Advanced querying foundations (review) |
| Level | Intermediate → Advanced |
| Estimated time | 2 h 30 min essential + 45 min optional stretch |
| Prerequisites | Days 1–6 |
| Syllabus topic | Week 1 Review & Integration — combine all Week 1 concepts |
| Foundations added today | The complete logical query processing order; TOP (with WITH TIES and PERCENT); paging with OFFSET … FETCH |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Logical query processing: the full picture | 20 min |
Part 2 — TOP and OFFSET … FETCH |
20 min |
| Part 3 — Choosing the right tool: a Week 1 decision guide | 15 min |
| Worked examples | 20 min |
| Guided lab: customer scorecard | 40 min |
| Independent practice | 30 min |
| Knowledge check (Week 1) and wrap-up | 5 min |
| Stretch challenge (optional) | 45 min |
Business scenario. The commercial director wants a customer scorecard for the 2026 planning meeting: one row per customer with their order history, value, recency, rank, value quartile and a simple segment label (Champion, At risk, New, Regular). It must reconcile to the finance figures. It is a realistic piece of analyst work that uses almost everything from Week 1.
B. Theory explained clearly
Part 1 — Logical query processing: the full picture
SQL Server is free to execute a query in any physical order it likes, but the result must be as if the clauses were processed in this logical order:
| Step | Clause | Produces | Can use |
|---|---|---|---|
| 1 | FROM, including each JOIN … ON from left to right |
The working set of rows | Table and table aliases |
| 2 | WHERE |
Rows that are TRUE | Columns from step 1 — not SELECT aliases, aggregates or window functions |
| 3 | GROUP BY |
One row per group | Expressions over step-2 columns |
| 4 | HAVING |
Groups that are TRUE | Grouped columns and aggregates — not SELECT aliases or window functions |
| 5 | SELECT |
Output expressions, aliases, window functions | Everything above; aliases defined here are not visible to each other in the same SELECT |
| 6 | DISTINCT |
Unique output rows | — |
| 7 | ORDER BY |
Sorted result | SELECT aliases, window functions, columns (with restrictions when DISTINCT is used) |
| 8 | TOP / OFFSET … FETCH |
A subset of the sorted rows | — |
This one table explains most of the “why does this fail?” moments of Week 1:
| Symptom | Explanation |
|---|---|
WHERE UnitMargin > 100 fails |
UnitMargin is defined at step 5; WHERE is step 2 |
HAVING UnitsSold > 40 fails |
Step 4 runs before the alias exists at step 5 |
WHERE RANK() OVER (…) <= 3 fails |
Window functions are computed at step 5 |
ORDER BY UnitMargin works |
Step 7 runs after step 5 |
A LEFT JOIN behaves like an inner join |
A WHERE filter (step 2) on the right-hand table removes the NULL rows produced at step 1 |
PctOfTotal ignores filtered rows |
Window functions (step 5) only see rows that survived step 2 |
Within a single SELECT list, one expression cannot refer to another’s alias: SELECT a + b AS c, c * 2 AS d fails. Use a CTE, a derived table or CROSS APPLY (Day 10).
When
DISTINCTis used,ORDER BYmay only refer to columns that appear in theSELECTlist, because sorting happens after duplicates are removed.
Part 2 — TOP and OFFSET … FETCH
B1. TOP
SELECT TOP (5) ProductCode, ListPrice
FROM Inventory.Product
ORDER BY ListPrice DESC, ProductCode;
| Form | Returns |
|---|---|
TOP (n) |
The first n rows of the ORDER BY |
TOP (n) WITH TIES |
The first n rows plus any further rows that tie with the n-th on the ORDER BY columns |
TOP (n) PERCENT |
The first n% of rows, rounded up |
Rules:
- Always use
ORDER BYwithTOP. Without it, SQL Server returns some n rows — whichever are cheapest to produce. They may differ between runs. WITH TIESrequiresORDER BY, and ties are judged only on theORDER BYcolumns. If you include a unique tie-breaker inORDER BY, there can be no ties, soWITH TIEShas no effect.- Use parentheses:
TOP (5). They are required when n is a variable or expression (TOP (@n)), and recommended always. TOPis T-SQL syntax. The ANSI equivalent isOFFSET 0 ROWS FETCH NEXT n ROWS ONLY.
TOP versus ROW_NUMBER: TOP returns the first n rows of the whole result. For “top n per group” you need the Day 5 pattern (ROW_NUMBER in a CTE), or CROSS APPLY with TOP (Day 10).
B2. OFFSET … FETCH
SELECT CustomerID, LastName, FirstName
FROM Sales.Customer
ORDER BY LastName, CustomerID
OFFSET 20 ROWS -- skip the first 20
FETCH NEXT 10 ROWS ONLY; -- return the next 10 (page 3 of 10 rows)
OFFSET … FETCHis part ofORDER BY; it cannot be used without it.OFFSET 0 ROWSis valid;FETCHis optional (omit it to return everything after the offset).- Values can be variables:
OFFSET (@PageNumber - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY. - The order must be unique, or a row can appear on two pages, or on none, as tied rows shuffle between executions.
LastNamealone is not unique (customers 3 and others may share surnames in future data);LastName, CustomerIDis. - Each page re-runs the query and skips rows; page 1,000 has to read and discard 9,990 rows first. For deep paging over large tables, keyset paging (
WHERE (LastName > @LastSeenName) OR (LastName = @LastSeenName AND CustomerID > @LastSeenID)) scales better. You will revisit this on Day 24. - Rows inserted or deleted between page requests shift later pages. Paging is a presentation technique, not a consistent snapshot.
Part 3 — Choosing the right tool
| Question shape | First choice | Alternatives |
|---|---|---|
| Compare a row with one overall value | Scalar subquery | Windowed aggregate OVER () |
| “Has at least one / has none” | EXISTS / NOT EXISTS |
IN; LEFT JOIN … IS NULL |
| Aggregate of an aggregate (average order value) | CTE or derived table | SUM ÷ COUNT(DISTINCT …) |
| Define a calculation once, filter on it | CTE | Derived table; CROSS APPLY (Day 10) |
| Use one intermediate result twice | CTE | Temporary table if expensive (Day 15) |
| Walk a hierarchy | Recursive CTE | hierarchyid (not covered) |
| One row per group with subtotals | GROUP BY + ROLLUP/CUBE/GROUPING SETS |
UNION ALL of grouped queries |
| Several totals side by side | Conditional aggregation SUM(CASE …) |
PIVOT (Day 11) |
| Rank or number rows, top-N per group, de-duplicate | ROW_NUMBER/RANK/DENSE_RANK in a CTE |
Correlated MAX subquery |
| Share of a group total | SUM(…) OVER (PARTITION BY …) |
Join to a grouped CTE |
| Previous/next row, gaps between events | LAG / LEAD |
Self-join on a key |
| Running total, moving average | Aggregate OVER (ORDER BY … ROWS …) |
Correlated subquery (slow) |
| First n rows overall, or a page | TOP / OFFSET … FETCH |
ROW_NUMBER |
And three habits that apply to every query:
- Know the grain of every intermediate result (one row per what?).
- Validate against a control total before building on a step.
- Make ordering deterministic wherever order matters — in
ORDER BY,OVER (ORDER BY …),TOPand paging.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product, Inventory.Category |
| Reference date | 31 December 2025 (“today” for recency calculations) |
| Permissions | SELECT on these tables |
| Objects created today | None — Day 7 is read-only |
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: 35, 369, 122354.88.
USE RetailLab;
GO
SELECT COUNT(DISTINCT o.CustomerID) AS OrderingCustomers,
COUNT(DISTINCT o.OrderID) AS NonCancelledOrders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled';
D. Worked examples
Worked example 1 — TOP with and without ties
WITH CustomerOrders AS
(
SELECT CustomerID, COUNT(*) AS Orders
FROM Sales.SalesOrder
WHERE OrderStatus <> 'Cancelled'
GROUP BY CustomerID
)
SELECT TOP (4) CustomerID, Orders
FROM CustomerOrders
ORDER BY Orders DESC; -- non-deterministic at the 4th row!
WITH CustomerOrders AS
(
SELECT CustomerID, COUNT(*) AS Orders
FROM Sales.SalesOrder
WHERE OrderStatus <> 'Cancelled'
GROUP BY CustomerID
)
SELECT TOP (4) WITH TIES CustomerID, Orders
FROM CustomerOrders
ORDER BY Orders DESC;
Expected result: the first query returns 4 rows: customers 1, 12 and 18 (27 orders each) and one of customers 6 or 26 (21 orders) — which one is not guaranteed. The second returns 5 rows, including both 6 and 26, because they tie on the ORDER BY value.
What to notice: adding CustomerID to the ORDER BY would make the first query deterministic (customer 6 would be chosen) but would make WITH TIES pointless. Decide which behaviour the business wants: “exactly four” or “everyone tied for the top four positions”.
Worked example 2 — Page 3 of the customer list
DECLARE @PageNumber int = 3,
@PageSize int = 10;
SELECT CustomerID, LastName, FirstName
FROM Sales.Customer
ORDER BY LastName, CustomerID
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
Expected result: 10 rows — Kelly (14), Lefèvre (33), Lindqvist (38), Lopez (23), MacDonald (8), Martin (11), Mehta (39), Morgan (1), Murphy (9), Okoye (28). Page 4 (@PageNumber = 4) returns the last 10 customers.
Worked example 3 — Alias visibility within a SELECT
-- Fails: Invalid column name 'UnitMargin'.
SELECT ProductCode,
ListPrice - StandardCost AS UnitMargin,
UnitMargin / NULLIF(ListPrice, 0) AS MarginRatio
FROM Inventory.Product;
-- Works: define the alias in one layer, use it in the next.
WITH m AS
(
SELECT ProductCode, ListPrice, ListPrice - StandardCost AS UnitMargin
FROM Inventory.Product
)
SELECT ProductCode, UnitMargin,
CAST(UnitMargin / NULLIF(ListPrice, 0) AS decimal(5,3)) AS MarginRatio
FROM m
ORDER BY MarginRatio DESC, ProductCode;
Expected result: the first statement fails; the second returns 30 rows. All aliases in one SELECT list are created “at the same time”, so none can see another.
Worked example 4 — One question, three correct solutions
“Which customers placed an order in December 2025 worth more than £600?”
-- A: EXISTS with a correlated aggregate
SELECT c.CustomerID
FROM Sales.Customer AS c
WHERE EXISTS (SELECT 1
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = c.CustomerID
AND o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
GROUP BY o.OrderID
HAVING SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) > 600)
ORDER BY c.CustomerID;
-- B: CTE + DISTINCT
WITH DecOrders AS
(
SELECT o.CustomerID, o.OrderID,
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'
AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
GROUP BY o.CustomerID, o.OrderID
)
SELECT DISTINCT CustomerID FROM DecOrders WHERE OrderNet > 600 ORDER BY CustomerID;
-- C: window function — largest December order per customer, then filter
WITH DecLines AS
(
SELECT o.CustomerID, o.OrderID,
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'
AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
GROUP BY o.CustomerID, o.OrderID
),
Biggest AS
(
SELECT CustomerID, MAX(OrderNet) OVER (PARTITION BY CustomerID) AS MaxDecOrder
FROM DecLines
)
SELECT DISTINCT CustomerID FROM Biggest WHERE MaxDecOrder > 600 ORDER BY CustomerID;
Expected result (all three): 6 customers — 1, 13, 17, 20, 23 and 28.
What to notice: all three are correct; A states the question most directly; B is the easiest to debug (run the CTE alone); C is overkill here but shows that window functions and grouping can be layered. Choose the version a colleague will understand.
If your result differs, run DecOrders on its own and inspect OrderNet: the six customers are those with at least one December order worth more than 600.00.
E. Guided practical lab — Customer scorecard
Build one row per ordering customer with: orders, lifetime net, average order value, first and last order dates, days since the last order (as at 31 December 2025), 2024 and 2025 net sales, value rank, value quartile and a segment.
Segment rules (agreed with the commercial director)
| Segment | Rule (first match wins) |
|---|---|
| Champion | Value quartile 1 and last order within 60 days |
| At risk | Value quartile 1 or 2 and last order more than 90 days ago |
| New | First order on or after 1 July 2025 |
| Regular | Everyone else |
Step 1 — One row per order (grain: order)
- Do: Create the base CTE.
- Run:
USE RetailLab; GO DECLARE @AsAt date = '2025-12-31'; WITH OrderTotals AS ( SELECT o.CustomerID, o.OrderID, 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.CustomerID, o.OrderID, o.OrderDate ) SELECT COUNT(*) AS Orders, CAST(SUM(OrderNet) AS decimal(12,2)) AS NetSales FROM OrderTotals; - Observe:
369,122354.88. - Why it matters: Control total: the scorecard must add up to this.
- Confirm: matches the validation query.
Step 2 — One row per customer (grain: customer), with conditional aggregation
- Do: Add a
CustomerStatsCTE afterOrderTotals. - Run: replace the final
SELECT:, CustomerStats AS ( SELECT CustomerID, COUNT(*) AS Orders, SUM(OrderNet) AS LifetimeNet, MIN(OrderDate) AS FirstOrder, MAX(OrderDate) AS LastOrder, SUM(CASE WHEN OrderDate < '2025-01-01' THEN OrderNet ELSE 0 END) AS Net2024, SUM(CASE WHEN OrderDate >= '2025-01-01' THEN OrderNet ELSE 0 END) AS Net2025 FROM OrderTotals GROUP BY CustomerID ) SELECT COUNT(*) AS Customers, CAST(SUM(LifetimeNet) AS decimal(12,2)) AS NetSales, CAST(SUM(Net2024) AS decimal(12,2)) AS Net2024, CAST(SUM(Net2025) AS decimal(12,2)) AS Net2025 FROM CustomerStats; - Observe:
35,122354.88,58612.94,63741.94. - Why it matters: Two grains, two control checks — the year split also matches Day 2.
- Confirm: the three totals match.
Step 3 — Add recency, rank and quartile (window functions over customers)
- Do: Add a
ScoredCTE. - Run: replace the final
SELECT:, Scored AS ( SELECT *, DATEDIFF(DAY, LastOrder, @AsAt) AS DaysSinceLast, RANK() OVER (ORDER BY LifetimeNet DESC) AS ValueRank, NTILE(4) OVER (ORDER BY LifetimeNet DESC, CustomerID) AS ValueQuartile FROM CustomerStats ) SELECT CustomerID, Orders, CAST(LifetimeNet AS decimal(12,2)) AS LifetimeNet, DaysSinceLast, ValueRank, ValueQuartile FROM Scored ORDER BY ValueRank, CustomerID; - Observe: 35 rows. Customer 1 is rank 1 (9301.71, 2 days since last order); customer 35 is rank 35.
- Why it matters: Window functions run on the 35 customer rows, after
GROUP BYin the previous CTE. - Confirm: quartile sizes 9, 9, 9, 8.
Step 4 — Apply the segment rules and finish the scorecard
-
Do: Replace the final
SELECTwith the full output. -
Run:
SELECT s.CustomerID, CONCAT_WS(N' ', c.FirstName, c.LastName) AS CustomerName, s.Orders, CAST(s.LifetimeNet AS decimal(12,2)) AS LifetimeNet, CAST(s.LifetimeNet / s.Orders AS decimal(10,2)) AS AvgOrderValue, s.FirstOrder, s.LastOrder, s.DaysSinceLast, CAST(s.Net2024 AS decimal(12,2)) AS Net2024, CAST(s.Net2025 AS decimal(12,2)) AS Net2025, s.ValueRank, s.ValueQuartile, CASE WHEN s.ValueQuartile = 1 AND s.DaysSinceLast <= 60 THEN 'Champion' WHEN s.ValueQuartile <= 2 AND s.DaysSinceLast > 90 THEN 'At risk' WHEN s.FirstOrder >= '2025-07-01' THEN 'New' ELSE 'Regular' END AS Segment FROM Scored AS s JOIN Sales.Customer AS c ON c.CustomerID = s.CustomerID ORDER BY s.ValueRank, s.CustomerID;(Highlight from
DECLAREto the end, so@AsAtis defined.) -
Observe: 35 rows. The first ten:
CustomerID CustomerName Orders LifetimeNet AvgOrderValue DaysSinceLast Net2024 Net2025 ValueRank ValueQuartile Segment 1 Alice Morgan 27 9301.71 344.51 2 4387.48 4914.22 1 1 Champion 18 Sofia Rossi 27 8883.52 329.02 26 6053.05 2830.47 2 1 Champion 12 Zoë Bennett 27 8810.01 326.30 8 5042.62 3767.39 3 1 Champion 6 Farah Ali 21 7178.50 341.83 24 3011.75 4166.75 4 1 Champion 26 Amelia Brooks 21 6561.86 312.47 1 3897.90 2663.96 5 1 Champion 29 Henry Shaw 16 6323.12 395.20 69 1843.87 4479.25 6 1 Regular 13 Mohammed Rahman 19 6062.85 319.10 4 1729.00 4333.85 7 1 Champion 4 David Thompson 18 5840.29 324.46 23 3497.60 2342.69 8 1 Champion 23 Xavier Lopez 18 5670.92 315.05 18 2767.74 2903.18 9 1 Champion 9 Jack Murphy 14 4905.59 350.40 20 4239.64 665.95 10 2 Regular -
Why it matters: Every column traces back to a validated step. The
JOINtoSales.Customerhappens last, at the customer grain, so it cannot multiply rows. -
Confirm: segment counts — 8 Champion, 2 At risk (customers 27 and 25), 1 New (customer 35), 24 Regular.
Step 5 — Challenge the rules
- Do: Look at customer 14 (Niamh Kelly, 554 days since last order, quartile 3) and customer 9 (Jack Murphy, 2025 sales down 84%). Both are Regular.
- Run: no new SQL — or filter the step-4 output with an outer query on
Segment = 'Regular' AND DaysSinceLast > 180. - Observe: the agreed rules miss lost lower-value customers and sharply declining high-value ones.
- Why it matters: SQL implements the rules precisely; whether the rules are good is a business question. Task 4 below quantifies the decliners.
- Confirm: you can propose one extra rule and say where in the
CASEit should go (order matters: first match wins).
F. Independent practice
Task 1 — Top products with ties
Return the 2025 products with the top 4 unit totals, including any products tied with the fourth (non-cancelled orders). Return ProductCode, ProductName and Units, sorted by units descending, then product code.
Success criteria: 4 rows — COK-003 (38), FTW-001 (30), JKT-001 (27) and TNT-002 (26). No other product sold 26 units, so WITH TIES adds nothing here. Then change the query to TOP (7) WITH TIES: it returns 9 rows, because three products tie on the seventh value, 22 units (BPK-003, JKT-003, TNT-003).
Hint
You need ORDER BY Units DESC alone in the TOP query for WITH TIES to work; then wrap it in an outer query (or a CTE) to sort the output by units and product code.
Task 2 — Paging a product catalogue
Using variables @PageNumber and @PageSize, return a page of products sorted by ListPrice descending then ProductCode. Return ProductCode, ProductName, ListPrice and RowNo (the position in the full sorted list).
Success criteria: with page size 8, page 2 returns positions 9–16: JKT-002 (189.00), TNT-001 (189.00), JKT-003, JKT-004 (159.00), FTW-001, FTW-002 (139.00), SLP-001 (129.00), FTW-003 (109.00). Page 4 returns 6 rows.
Hint
ROW_NUMBER() OVER (ORDER BY ListPrice DESC, ProductCode) gives RowNo; the same order in the final ORDER BY … OFFSET … FETCH keeps them consistent.
Task 3 — Each category’s two best months in 2025
For each top-level category, return its two best months of 2025 by net sales: TopCategory, MonthNo, NetSales, MonthRank and PctOfCategoryYear (1 decimal place). Sort by category and rank.
Success criteria: 6 rows. Accessories: December (2907.17, 24.8%) and June (1704.60, 14.5%). Camping: April (3826.05) and December (3626.90). Clothing: May (3349.25) and December (3184.58).
Hint
Day 4’s CategoryTree → monthly sales per top category (GROUP BY) → RANK() and SUM() OVER (PARTITION BY …) in a third CTE → filter.
Task 4 — Sharply declining customers (challenging)
Find customers who bought in 2024 and whose 2025 net sales are less than half of their 2024 net sales (including customers with no 2025 sales). Return CustomerID, Net2024, Net2025, ChangePct (1 decimal place) and DeclineRank (1 = largest drop in pounds). Sort by DeclineRank.
Success criteria: 9 rows. Rank 1: customer 9 (4239.64 → 665.95, −84.3%). Rank 2: customer 18 (6053.05 → 2830.47, −53.2%). Customer 14 appears with 0.00 and −100.0%.
Hint
Reuse OrderTotals and conditional aggregation from the lab. The rank orders by Net2024 - Net2025 descending.
Stretch challenge — Cohort retention
Group ordering customers into cohorts by the quarter of their first order (e.g. 2024-Q1). For each cohort return Cohort, Customers, ActiveH2_2025 (customers with at least one order from 1 July 2025) and RetentionPct. Sort by cohort.
Success criteria: 6 cohorts. 2024-Q1: 19 customers, 16 active, 84.2%. Every later cohort shows 100.0%. Cohorts with no members (2025-Q2, 2025-Q3) do not appear.
Hint
CONCAT(YEAR(FirstOrder), '-Q', DATEPART(QUARTER, FirstOrder)) builds the label. Count active customers with conditional aggregation over a per-customer CTE.
G. Solutions and explanations
Show solution 1
WITH Units2025 AS
(
SELECT p.ProductCode, p.ProductName, SUM(l.Quantity) AS Units
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY p.ProductCode, p.ProductName
),
TopUnits AS
(
SELECT TOP (4) WITH TIES ProductCode, ProductName, Units
FROM Units2025
ORDER BY Units DESC -- no tie-breaker here, on purpose
)
SELECT ProductCode, ProductName, Units
FROM TopUnits
ORDER BY Units DESC, ProductCode; -- deterministic presentation order
Result: 4 rows — COK-003 (38), FTW-001 (30), JKT-001 (27), TNT-002 (26). With TOP (7) WITH TIES: 9 rows — the six products with 38, 30, 27, 26, 25 (BPK-001) and 24 (COK-002) units, plus all three products with 22 units (BPK-003, JKT-003, TNT-003), because the seventh row’s value is shared. TOP (6) WITH TIES would return exactly 6 rows: the sixth value, 24, is not tied.
Reasoning. A TOP inside a CTE is allowed together with ORDER BY; the outer ORDER BY decides the displayed order. Always check whether ties actually exist at the boundary before relying on WITH TIES.
Show solution 2
DECLARE @PageNumber int = 2,
@PageSize int = 8;
SELECT ProductCode,
ProductName,
ListPrice,
ROW_NUMBER() OVER (ORDER BY ListPrice DESC, ProductCode) AS RowNo
FROM Inventory.Product
ORDER BY ListPrice DESC, ProductCode
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
Result: positions 9 to 16 as listed in the success criteria. ROW_NUMBER is computed at step 5 over all 30 rows, before OFFSET … FETCH (step 8) removes rows, so RowNo shows the global position. Page 4 starts at position 25 and returns the last 6 products. The two orderings must match; if ROW_NUMBER used a different order from the ORDER BY, the numbers would look scrambled.
Show solution 3
WITH CategoryTree AS
(
SELECT CategoryID, CategoryName AS TopCategory
FROM Inventory.Category WHERE ParentCategoryID IS NULL
UNION ALL
SELECT c.CategoryID, t.TopCategory
FROM Inventory.Category AS c
JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID
),
MonthlyCat AS
(
SELECT t.TopCategory,
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
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY t.TopCategory, MONTH(o.OrderDate)
),
Ranked AS
(
SELECT *,
RANK() OVER (PARTITION BY TopCategory ORDER BY NetSales DESC) AS MonthRank,
100.0 * NetSales / SUM(NetSales) OVER (PARTITION BY TopCategory) AS PctOfCategoryYear
FROM MonthlyCat
)
SELECT TopCategory, MonthNo,
CAST(NetSales AS decimal(12,2)) AS NetSales,
MonthRank,
CAST(PctOfCategoryYear AS decimal(5,1)) AS PctOfCategoryYear
FROM Ranked
WHERE MonthRank <= 2
ORDER BY TopCategory, MonthRank;
| TopCategory | MonthNo | NetSales | MonthRank | PctOfCategoryYear |
|---|---|---|---|---|
| Accessories | 12 | 2907.17 | 1 | 24.8 |
| Accessories | 6 | 1704.60 | 2 | 14.5 |
| Camping | 4 | 3826.05 | 1 | 13.9 |
| Camping | 12 | 3626.90 | 2 | 13.2 |
| Clothing | 5 | 3349.25 | 1 | 13.7 |
| Clothing | 12 | 3184.58 | 2 | 13.0 |
Reasoning. The percentage is computed before filtering, so it reflects the whole year (Day 5’s trap avoided). December is in every category’s top two — Christmas — while each category also has its own peak.
Show solution 4
WITH OrderTotals AS
(
SELECT 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.CustomerID, o.OrderID, o.OrderDate
),
YearSplit AS
(
SELECT CustomerID,
SUM(CASE WHEN OrderDate < '2025-01-01' THEN OrderNet ELSE 0 END) AS Net2024,
SUM(CASE WHEN OrderDate >= '2025-01-01' THEN OrderNet ELSE 0 END) AS Net2025
FROM OrderTotals
GROUP BY CustomerID
)
SELECT CustomerID,
CAST(Net2024 AS decimal(12,2)) AS Net2024,
CAST(Net2025 AS decimal(12,2)) AS Net2025,
CAST(100.0 * (Net2025 - Net2024) / Net2024 AS decimal(6,1)) AS ChangePct,
RANK() OVER (ORDER BY Net2024 - Net2025 DESC) AS DeclineRank
FROM YearSplit
WHERE Net2024 > 0
AND Net2025 < 0.5 * Net2024
ORDER BY DeclineRank, CustomerID;
| CustomerID | Net2024 | Net2025 | ChangePct | DeclineRank |
|---|---|---|---|---|
| 9 | 4239.64 | 665.95 | -84.3 | 1 |
| 18 | 6053.05 | 2830.47 | -53.2 | 2 |
| 14 | 2086.00 | 0.00 | -100.0 | 3 |
| 3 | 1766.98 | 351.99 | -80.1 | 4 |
| 31 | 1449.70 | 159.00 | -89.0 | 5 |
| 25 | 1993.99 | 834.99 | -58.1 | 6 |
| 21 | 1229.00 | 577.98 | -53.0 | 7 |
| 11 | 795.70 | 157.98 | -80.1 | 8 |
| 7 | 397.00 | 159.00 | -59.9 | 9 |
Reasoning. Net2024 > 0 both enforces “bought in 2024” and protects the division. Customer 18 is a Champion in the scorecard yet more than halved their spend — a strong argument for adding a trend rule to the segments. The window function ranks over the filtered rows, which is what “rank among decliners” means.
Show the stretch solution
WITH CustomerActivity AS
(
SELECT CustomerID,
MIN(OrderDate) AS FirstOrder,
MAX(CASE WHEN OrderDate >= '2025-07-01' THEN 1 ELSE 0 END) AS ActiveH2_2025
FROM Sales.SalesOrder
WHERE OrderStatus <> 'Cancelled'
GROUP BY CustomerID
)
SELECT CONCAT(YEAR(FirstOrder), '-Q', DATEPART(QUARTER, FirstOrder)) AS Cohort,
COUNT(*) AS Customers,
SUM(ActiveH2_2025) AS ActiveH2_2025,
CAST(100.0 * SUM(ActiveH2_2025) / COUNT(*) AS decimal(5,1)) AS RetentionPct
FROM CustomerActivity
GROUP BY CONCAT(YEAR(FirstOrder), '-Q', DATEPART(QUARTER, FirstOrder))
ORDER BY Cohort;
| Cohort | Customers | ActiveH2_2025 | RetentionPct |
|---|---|---|---|
| 2024-Q1 | 19 | 16 | 84.2 |
| 2024-Q2 | 8 | 8 | 100.0 |
| 2024-Q3 | 5 | 5 | 100.0 |
| 2024-Q4 | 1 | 1 | 100.0 |
| 2025-Q1 | 1 | 1 | 100.0 |
| 2025-Q4 | 1 | 1 | 100.0 |
Reasoning. MAX(CASE … THEN 1 ELSE 0 END) turns “any order in H2 2025” into a 0/1 flag per customer. Sorting the text label works because it is zero-padded by design (2024-Q1 … 2025-Q4). Small cohorts make 100% figures meaningless — always show the denominator. A full cohort matrix (cohort × activity quarter) is a natural extension using conditional aggregation or PIVOT (Day 11).
H. Knowledge check (Week 1)
-
Understanding. Put these clauses in logical processing order:
ORDER BY,HAVING,SELECT,FROM,WHERE,GROUP BY,OFFSET … FETCH. -
Query interpretation. What does
SELECT TOP (3) WITH TIES ProductCode FROM Inventory.Product ORDER BY ListPrice DESC;return, and how many rows? -
Debugging. A paging query uses
ORDER BY LastName OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY. Users report a customer appearing on pages 1 and 2. Why? -
Debugging. Why does
SELECT CustomerID, COUNT(*) AS Orders FROM Sales.SalesOrder WHERE Orders > 5 GROUP BY CustomerID;fail, and what are two correct alternatives? -
Mixed. For each of these, name the Week 1 technique you would reach for first: (a) each employee’s whole management chain; (b) month-on-month change; (c) the three most expensive products per category; (d) customers who never bought a jacket; (e) sales by year with a grand total row.
Show the answers
-
FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→OFFSET … FETCH. -
The products tied at the top three prices: TNT-004 (599.00), then NAV-004 and TNT-002 (349.00) — the third row’s price is 349.00 and there are no further 349.00 products, so 3 rows. (If the third and fourth were tied,
WITH TIESwould add the fourth.) -
LastNameis not unique; tied rows can be returned in a different order on each execution, so one may cross the page boundary. Add a unique tie-breaker such asCustomerID. -
Ordersis aSELECTalias (step 5) and an aggregate cannot be used inWHERE(step 2) anyway. UseHAVING COUNT(*) > 5, or compute the count in a CTE and filterWHERE Orders > 5in the outer query. -
(a) Recursive CTE (upwards). (b)
LAG. (c)ROW_NUMBER/RANKpartitioned by category, in a CTE. (d)NOT EXISTS. (e)GROUP BY ROLLUP.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| The TOP N WITH TIES clause is not allowed without a corresponding ORDER BY clause. | WITH TIES without ORDER BY |
Add ORDER BY |
WITH TIES returns exactly n rows despite visible ties |
A unique tie-breaker in ORDER BY |
Remove it from the TOP query; sort in an outer query |
Invalid usage of the option NEXT in the FETCH statement. / syntax errors near OFFSET |
OFFSET … FETCH without ORDER BY, or in the wrong position |
It must follow ORDER BY |
| Rows duplicated or missing across pages | Non-unique ORDER BY |
Add a unique tie-breaker |
TOP returns different rows each run |
No ORDER BY |
Always order TOP queries |
Invalid column name for an alias in the same SELECT |
Aliases in one SELECT are created simultaneously |
Layer with a CTE or derived table |
| Scorecard totals don’t reconcile | Grain change (joining lines after aggregating, or customers before) | Validate each CTE; join descriptive tables last |
Cleanup. Day 7 is read-only; nothing to clean up. RetailLab stays in its core state for Week 2.
J. Completion checklist, recap and references
Completion checklist
- You can reproduce the 8-step logical processing table from memory.
- Worked example 1 showed 4 rows without and 5 rows with
WITH TIES. - Your scorecard has 35 rows, reconciles to 122354.88 and shows 8 / 2 / 1 / 24 segment counts.
- Tasks 1–4 match the success criteria (4, 8, 6 and 9 rows).
- You answered the Week 1 knowledge check without looking back.
Week 1 recap
| Day | Core idea |
|---|---|
| 1 | Filtering, CASE, three-valued logic and NULL functions |
| 2 | Aggregation, grain, HAVING, ROLLUP/CUBE/GROUPING SETS, conditional aggregation |
| 3 | Subqueries, EXISTS, the NOT IN NULL trap, derived tables, LEFT JOIN anti-joins |
| 4 | CTEs, recursion over hierarchies and date series |
| 5 | Ranking functions, top-N per group, de-duplication, shares of totals |
| 6 | LAG/LEAD, running totals, frames (ROWS vs RANGE), moving averages |
| 7 | Logical processing order, TOP/OFFSET … FETCH, choosing and combining techniques |
Next: Day 8 — Advanced JOIN techniques: every join type, row multiplication, unmatched rows, NULLs in join conditions and where to put filters.