Day 5: Window functions, part 1
On this page
By the end of this lesson you can
- Explain how a window function differs from
GROUP BY: it adds a calculated column without collapsing rows. - Define a window with
OVER (PARTITION BY … ORDER BY …)and explain what each part does. - Choose between
ROW_NUMBER,RANK,DENSE_RANKandNTILE, predicting their output when values tie. - Make
ROW_NUMBERdeterministic with a unique tie-breaker. - Return the top N rows per group and remove duplicate rows by filtering on a row number in a CTE.
- Compute each row's share of a group total with
SUM(…) OVER (PARTITION BY …). - Apply a window function to the result of a
GROUP BY.
A. Lesson overview
| Week | 1 — Advanced querying foundations |
| Level | Intermediate |
| Estimated time | 2 h 30 min essential + 30 min optional stretch |
| Prerequisites | Days 1–4, especially GROUP BY, derived tables and CTEs |
| Syllabus topic | Window Functions Part 1 — ROW_NUMBER, RANK, DENSE_RANK, NTILE |
| Foundations added today | Windowed aggregates (SUM(…) OVER (…)) for shares of a total — the bridge to Day 6 |
Activity breakdown
| Activity | Time |
|---|---|
Part 1 — What a window is: OVER, PARTITION BY, ORDER BY |
20 min |
| Part 2 — The four ranking functions and ties | 25 min |
| Part 3 — Filtering on a rank: top-N per group and de-duplication | 20 min |
| Part 4 — Windowed aggregates and window functions over grouped rows | 15 min |
| Worked examples | 25 min |
| Guided lab | 25 min |
| Independent practice | 15 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. It is bonus season. HR needs salary rankings with fair treatment of ties. Sales wants league tables: the best products in each category, the top customers in each country, the leading salesperson in each shop. Data engineering needs to clean duplicate records in the staging tables before loading them. All of these are “rank within a group” problems — exactly what window functions solve, cleanly and efficiently.
B. Theory explained clearly
Part 1 — What a window is
B1. Window functions versus GROUP BY
GROUP BY collapses each group to one row. A window function computes a value from a set of rows — the window — but returns one value for every row, so you keep the detail and gain the calculation.
USE RetailLab;
GO
SELECT EmployeeID,
FirstName,
DepartmentID,
Salary,
AVG(Salary) OVER (PARTITION BY DepartmentID) AS DeptAvgSalary -- same value on every row of the department
FROM HR.Employee
ORDER BY DepartmentID, EmployeeID;
All 16 employees are returned, each next to their department’s average. Compare Day 3, where you needed a correlated subquery for the same thing.
B2. The OVER clause
function_name ( arguments ) OVER ( [PARTITION BY expr, …] [ORDER BY expr [ASC|DESC], …] )
| Part | Meaning | If omitted |
|---|---|---|
PARTITION BY |
Splits rows into independent groups; the function restarts in each | The whole result set is one partition |
ORDER BY (inside OVER) |
Orders rows within each partition for the calculation | Required for ranking functions; for aggregates, see Day 6 |
The ORDER BY inside OVER controls the calculation only. It does not sort the output: you still need a final ORDER BY for that.
B3. Where window functions fit in logical processing order
| Step | Clause |
|---|---|
| 1 | FROM / JOIN |
| 2 | WHERE |
| 3 | GROUP BY |
| 4 | HAVING |
| 5 | SELECT — window functions are evaluated here |
| 6 | DISTINCT |
| 7 | ORDER BY |
Consequences:
- Window functions see only rows that survived
WHERE,GROUP BYandHAVING. - Window functions cannot be used in
WHERE,GROUP BYorHAVING(error 4108: Windowed functions can only appear in the SELECT or ORDER BY clauses.). To filter on a rank, compute it in a CTE or derived table and filter in the outer query — Part 3. - They can be used in the final
ORDER BY.
Part 2 — The four ranking functions
B4. ROW_NUMBER, RANK, DENSE_RANK and NTILE
| Function | Returns | With ties |
|---|---|---|
ROW_NUMBER() |
1, 2, 3, … within the partition | Ties get different numbers, assigned arbitrarily unless ORDER BY is unique |
RANK() |
Position counting ties | Ties share a rank; the next rank skips (1, 2, 2, 4) |
DENSE_RANK() |
Position counting distinct values | Ties share a rank; no gaps (1, 2, 2, 3) |
NTILE(n) |
Bucket number 1 to n of roughly equal size | Ties may be split across buckets |
All four require ORDER BY inside OVER.
Which one should I use?
- “Give every row a unique position” (paging, picking exactly one row per group, de-duplication) →
ROW_NUMBERwith a unique tie-breaker. - “Sports-table ranking: two people joint-second, next person fourth” →
RANK. - “Top 3 distinct values, however many rows share them” →
DENSE_RANK. - “Split into quartiles/deciles” →
NTILE.
B5. Deterministic ROW_NUMBER
Four pairs of employees share a salary. With ROW_NUMBER() OVER (ORDER BY Salary DESC), the order of Tom Baxter and Aisha Khan (both £72,000) is not defined — SQL Server may number them either way, and the choice can change between executions, servers or after an index change. Add a unique column as the final sort key: ORDER BY Salary DESC, EmployeeID.
The same rule applies to NTILE: when the boundary between two buckets falls inside a group of ties, a unique tie-breaker decides which bucket each tied row lands in.
B6. How NTILE sizes its buckets
NTILE(n) divides the partition’s row count by n. If it doesn’t divide evenly, the first buckets get one extra row each. 35 customers into 4 quartiles → 9, 9, 9, 8. 16 employees into 4 → 4, 4, 4, 4.
NTILE divides by row count, not by value. Quartile 1 is “the top quarter of rows”, not “values above the 75th percentile”. The two can differ considerably when values are skewed or tied.
Part 3 — Filtering on a rank
B7. The top-N-per-group pattern
WITH Ranked AS
(
SELECT …,
ROW_NUMBER() OVER (PARTITION BY GroupColumn ORDER BY Measure DESC, UniqueKey) AS rn
FROM …
)
SELECT …
FROM Ranked
WHERE rn <= 3 -- top 3 per group
ORDER BY GroupColumn, rn;
Swap ROW_NUMBER for RANK or DENSE_RANK if tied rows at the boundary should all be included. This pattern replaces the “row with the group’s maximum” correlated subqueries from Day 3, reads the data once and lets you pick exactly one row per group when there are ties.
B8. De-duplication
Duplicate business records — the same person imported twice — are a ranking problem too: partition by the columns that define “the same record”, order by which copy you want to keep, and keep rn = 1.
WITH Numbered AS
(
SELECT ImportRowID, Email,
ROW_NUMBER() OVER (PARTITION BY TRIM(Email) ORDER BY ImportRowID) AS rn
FROM Staging.CustomerImport
)
SELECT * FROM Numbered WHERE rn > 1; -- the copies to discard
TRIM(SQL Server 2017 and later) removes leading and trailing spaces.RetailLabuses a case-insensitive collation, soKOFI.MENSAH@EXAMPLE.COMandkofi.mensah@example.comfall into the same partition withoutLOWER. On a case-sensitive database you would addLOWER(…).- All NULL emails form one partition. Decide whether that is right — two rows with no email are not necessarily the same person. Today’s examples flag rather than delete; Day 15 performs the actual
DELETEthrough a CTE.
Part 4 — Windowed aggregates
B9. Aggregates with OVER
SUM, COUNT, AVG, MIN and MAX can all take an OVER clause. Without ORDER BY inside OVER, the window is the whole partition:
SELECT ProductCode, CategoryID, ListPrice,
COUNT(*) OVER (PARTITION BY CategoryID) AS ProductsInCategory,
MAX(ListPrice) OVER (PARTITION BY CategoryID) AS CategoryMaxPrice,
CAST(100.0 * ListPrice / SUM(ListPrice) OVER () AS decimal(5,2)) AS PctOfCatalogueValue
FROM Inventory.Product
ORDER BY CategoryID, ProductCode;
OVER () with empty parentheses means “all rows of the result”. Adding ORDER BY inside an aggregate’s OVER turns it into a running total — Day 6.
B10. Window functions over grouped rows
When a query has GROUP BY, window functions run after grouping, so they operate on the grouped rows. That means you can rank or share aggregates directly:
SELECT o.CustomerID,
COUNT(*) AS Orders,
RANK() OVER (ORDER BY COUNT(*) DESC) AS OrderRank,
CAST(100.0 * COUNT(*) / SUM(COUNT(*)) OVER () AS decimal(5,2)) AS PctOfAllOrders
FROM Sales.SalesOrder AS o
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.CustomerID
ORDER BY OrderRank, o.CustomerID;
SUM(COUNT(*)) OVER () looks odd but reads naturally: the inner COUNT(*) is the group aggregate, the outer SUM … OVER () adds those counts across all groups. Any column used inside the window function must be either grouped or wrapped in an aggregate — the golden rule from Day 2 still applies.
Optional syntax (SQL Server 2022 and later, compatibility level 160+): a
WINDOWclause can name a window once and reuse it —… OVER w … WINDOW w AS (PARTITION BY DepartmentID ORDER BY Salary DESC). The course writes windows in full so the code also runs on older versions.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | HR.Employee, Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product, Inventory.Category, Staging.CustomerImport, Staging.ProductPriceUpdate |
| Teaching cases used | Tied salaries (4 pairs); customers tied on order counts; duplicate staging rows that differ only by case and spacing |
| Permissions | SELECT on these tables |
| Objects created today | None — Day 5 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: 16, 12, 15, 10.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM HR.Employee) AS Employees,
(SELECT COUNT(DISTINCT Salary) FROM HR.Employee) AS DistinctSalaries,
(SELECT COUNT(*) FROM Staging.CustomerImport) AS ImportRows,
(SELECT COUNT(*) FROM Staging.ProductPriceUpdate) AS PriceUpdateRows;
D. Worked examples
Worked example 1 — Four ranking functions side by side
SELECT EmployeeID,
CONCAT_WS(N' ', FirstName, LastName) AS EmployeeName,
Salary,
ROW_NUMBER() OVER (ORDER BY Salary DESC, EmployeeID) AS RowNum,
RANK() OVER (ORDER BY Salary DESC) AS RankNo,
DENSE_RANK() OVER (ORDER BY Salary DESC) AS DenseRankNo,
NTILE(4) OVER (ORDER BY Salary DESC, EmployeeID) AS Quartile
FROM HR.Employee
ORDER BY Salary DESC, EmployeeID;
Expected result: 16 rows.
| EmployeeID | EmployeeName | Salary | RowNum | RankNo | DenseRankNo | Quartile |
|---|---|---|---|---|---|---|
| 1 | Eleanor Hughes | 120000.00 | 1 | 1 | 1 | 1 |
| 2 | Raj Patel | 85000.00 | 2 | 2 | 2 | 1 |
| 3 | Fiona Campbell | 78000.00 | 3 | 3 | 3 | 1 |
| 4 | Tom Baxter | 72000.00 | 4 | 4 | 4 | 1 |
| 5 | Aisha Khan | 72000.00 | 5 | 4 | 4 | 2 |
| 6 | Liam O’Connor | 52000.00 | 6 | 6 | 5 | 2 |
| 11 | Gareth Price | 38000.00 | 7 | 7 | 6 | 2 |
| 7 | Sophie Turner | 34000.00 | 8 | 8 | 7 | 2 |
| 8 | Daniel Okafor | 34000.00 | 9 | 8 | 7 | 3 |
| 16 | James Reid | 32000.00 | 10 | 10 | 8 | 3 |
| 9 | Megan Lewis | 31500.00 | 11 | 11 | 9 | 3 |
| 10 | Chen Wei | 31500.00 | 12 | 11 | 9 | 3 |
| 15 | Oliver Grant | 30000.00 | 13 | 13 | 10 | 4 |
| 14 | Priya Sharma | 29000.00 | 14 | 14 | 11 | 4 |
| 12 | Hannah Scott | 26500.00 | 15 | 15 | 12 | 4 |
| 13 | Marek Nowak | 26500.00 | 16 | 15 | 12 | 4 |
What to notice
- Tom and Aisha tie:
RANKgives both 4 and skips 5;DENSE_RANKgives both 4 and continues with 5. NTILE(4)puts Tom in quartile 1 and Aisha in quartile 2 although they earn the same. If HR’s bonus rules depended on quartile, that would be unfair — use a rank-based rule instead, or agree how ties at a boundary are treated.- The last
RANK(15) plus the tie equals the row count (16). The lastDENSE_RANK(12) equals the number of distinct salaries.
Worked example 2 — Ranking within each department
SELECT DepartmentID,
EmployeeID,
FirstName,
Salary,
RANK() OVER (PARTITION BY DepartmentID ORDER BY Salary DESC) AS RankInDept
FROM HR.Employee
ORDER BY DepartmentID, RankInDept, EmployeeID;
Expected result: 16 rows; ranks restart at 1 in each department. Sales (department 2): Raj 1, Liam 2, Sophie and Daniel 3, James 5, Megan and Chen 6, Oliver 8. Warehouse & Logistics (3): Fiona 1, Gareth 2, Hannah and Marek 3.
Worked example 3 — Top 2 customers per country (top-N per group)
WITH CustomerSales AS
(
SELECT c.Country,
o.CustomerID,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS LifetimeNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Sales.Customer AS c ON c.CustomerID = o.CustomerID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY c.Country, o.CustomerID
),
Ranked AS
(
SELECT Country,
CustomerID,
LifetimeNet,
ROW_NUMBER() OVER (PARTITION BY Country ORDER BY LifetimeNet DESC, CustomerID) AS rn,
COUNT(*) OVER (PARTITION BY Country) AS CustomersInCountry
FROM CustomerSales
)
SELECT Country, rn AS Position, CustomerID,
CAST(LifetimeNet AS decimal(12,2)) AS LifetimeNet,
CustomersInCountry
FROM Ranked
WHERE rn <= 2
ORDER BY Country, rn;
Expected result: 12 rows. Countries with one ordering customer (Denmark, Italy, Spain, United States) return one row each.
| Country | Position | CustomerID | LifetimeNet | CustomersInCountry |
|---|---|---|---|---|
| Denmark | 1 | 27 | 3206.00 | 1 |
| France | 1 | 21 | 1806.98 | 3 |
| France | 2 | 33 | 1574.13 | 3 |
| Germany | 1 | 30 | 3212.97 | 2 |
| Germany | 2 | 15 | 3051.98 | 2 |
| Ireland | 1 | 9 | 4905.59 | 3 |
| Ireland | 2 | 10 | 2133.21 | 3 |
| Italy | 1 | 18 | 8883.52 | 1 |
| Spain | 1 | 23 | 5670.92 | 1 |
| United Kingdom | 1 | 1 | 9301.71 | 23 |
| United Kingdom | 2 | 12 | 8810.01 | 23 |
| United States | 1 | 35 | 505.00 | 1 |
The INNER JOIN to Sales.Customer is a three-table join: each additional JOIN … ON … adds one table. CustomersInCountry shows a windowed COUNT living alongside a ranking function.
Worked example 4 — Largest order per customer, the clean way
Day 3 Task 4 needed the per-order derived table twice. With ROW_NUMBER:
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
),
Ranked AS
(
SELECT *,
ROW_NUMBER() OVER (PARTITION BY CustomerID
ORDER BY OrderNet DESC, OrderDate DESC, OrderID DESC) AS rn
FROM OrderTotals
)
SELECT CustomerID, OrderID, OrderDate, CAST(OrderNet AS decimal(10,2)) AS OrderNet
FROM Ranked
WHERE rn = 1
ORDER BY OrderNet DESC, CustomerID;
Expected result: the same 35 rows as Day 3, from customer 26 (order 1014, 1577.70) to customer 7 (order 1142, 248.00). If two orders tied, the tie-breakers pick the more recent one — an explicit business rule rather than “return both”.
Worked example 5 — Customer quartiles with NTILE
WITH CustomerSales AS
(
SELECT o.CustomerID,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS LifetimeNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.CustomerID
),
Bucketed AS
(
SELECT CustomerID, LifetimeNet,
NTILE(4) OVER (ORDER BY LifetimeNet DESC, CustomerID) AS Quartile
FROM CustomerSales
)
SELECT Quartile,
COUNT(*) AS Customers,
CAST(MIN(LifetimeNet) AS decimal(12,2)) AS LowestNet,
CAST(MAX(LifetimeNet) AS decimal(12,2)) AS HighestNet
FROM Bucketed
GROUP BY Quartile
ORDER BY Quartile;
Expected result
| Quartile | Customers | LowestNet | HighestNet |
|---|---|---|---|
| 1 | 9 | 5670.92 | 9301.71 |
| 2 | 9 | 2828.98 | 4905.59 |
| 3 | 9 | 1574.13 | 2428.96 |
| 4 | 8 | 505.00 | 1464.96 |
35 rows ÷ 4 = 8 remainder 3, so the first three quartiles get 9 customers each.
Worked example 6 — Share of total with windowed aggregates
WITH ProductSales AS
(
SELECT p.CategoryID,
p.ProductCode,
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
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY p.CategoryID, p.ProductCode
)
SELECT CategoryID,
ProductCode,
CAST(NetSales AS decimal(12,2)) AS NetSales,
CAST(100.0 * NetSales / SUM(NetSales) OVER (PARTITION BY CategoryID) AS decimal(5,1)) AS PctOfCategory,
CAST(100.0 * NetSales / SUM(NetSales) OVER () AS decimal(5,1)) AS PctOfTotal
FROM ProductSales
WHERE CategoryID = 2 -- tents only, to keep the output short
ORDER BY NetSales DESC, ProductCode;
Expected result: 3 rows — TNT-002 (8777.35), TNT-003 (5241.45), TNT-001 (3562.65), with PctOfCategory 49.9, 29.8 and 20.3.
Trap: look at PctOfTotal. It shows 49.9, 29.8 and 20.3 — the same as PctOfCategory, adding up to 100. The WHERE CategoryID = 2 filter ran before the window function, so SUM(…) OVER () only saw tents. To show the share of all 2025 sales, filter in an outer query instead:
-- …same CTE…
SELECT * FROM ( SELECT CategoryID, ProductCode, NetSales,
100.0 * NetSales / SUM(NetSales) OVER () AS PctOfTotal
FROM ProductSales ) AS x
WHERE x.CategoryID = 2; -- now PctOfTotal = 13.8, 8.2, 5.6
Worked example 7 — Flag duplicate staging rows
WITH Numbered AS
(
SELECT ImportRowID,
FullName,
Email,
ROW_NUMBER() OVER (PARTITION BY TRIM(Email) ORDER BY ImportRowID) AS CopyNo,
COUNT(*) OVER (PARTITION BY TRIM(Email)) AS CopiesOfEmail
FROM Staging.CustomerImport
)
SELECT ImportRowID, FullName, Email, CopyNo, CopiesOfEmail,
CASE WHEN Email IS NULL THEN 'Review: no email'
WHEN CopyNo > 1 THEN 'Duplicate - discard'
ELSE 'Keep'
END AS Action
FROM Numbered
ORDER BY ImportRowID;
Expected result: 15 rows. Rows 4 and 5 (copies of Daisy Holt, row 3) and row 13 (copy of Kofi Mensah, row 6) are Duplicate – discard. Row 12 has no email and is flagged for review. The other 11 rows are Keep.
E. Guided practical lab — 2025 product league table
Merchandising wants a league table of 2025 products: overall rank, rank within top-level category, quartile, and share of the category’s sales. Then they want the top 3 per category.
Step 1 — Prepare one row per product with its top-level category
- Do: Reuse Day 4’s tree to map categories to top-level names, and compute 2025 sales per product.
- Run:
USE RetailLab; GO 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 ), ProductSales AS ( SELECT t.TopCategory, p.ProductCode, p.ProductName, 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 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, p.ProductCode, p.ProductName ) SELECT COUNT(*) AS Products, CAST(SUM(NetSales) AS decimal(12,2)) AS NetSales FROM ProductSales; - Observe:
28,63741.94. - Why it matters: Control total confirmed before ranking anything.
- Confirm: 28 products; 63741.94.
Step 2 — Add the rankings
-
Do: Replace the final
SELECTwith one that adds window functions. -
Run:
SELECT TopCategory, ProductCode, ProductName, Units, CAST(NetSales AS decimal(12,2)) AS NetSales, RANK() OVER (ORDER BY NetSales DESC) AS OverallRank, ROW_NUMBER() OVER (PARTITION BY TopCategory ORDER BY NetSales DESC, ProductCode) AS RankInCategory, NTILE(4) OVER (ORDER BY NetSales DESC, ProductCode) AS Quartile, CAST(100.0 * NetSales / SUM(NetSales) OVER (PARTITION BY TopCategory) AS decimal(5,1)) AS PctOfCategory FROM ProductSales ORDER BY OverallRank, ProductCode; -
Observe: 28 rows. The first five:
TopCategory ProductCode Units NetSales OverallRank RankInCategory Quartile PctOfCategory Camping TNT-002 26 8777.35 1 1 1 31.8 Camping TNT-003 22 5241.45 2 2 1 19.0 Camping SLP-002 17 4903.60 3 3 1 17.8 Clothing JKT-001 27 4866.75 4 1 1 19.9 Accessories NAV-002 14 4021.55 5 1 1 34.3 The last row is COK-003 (Gas Canister): rank 28, quartile 4, 0.8% of Camping — yet it sold the most units (38).
-
Why it matters: Four different windows in one query, each with its own partition and order, all computed over the same 28 rows.
-
Confirm: 28 rows; each quartile contains 7 products (28 ÷ 4).
Step 3 — Top 3 per category
-
Do: Window functions cannot go in
WHERE. Turn the step-2 query into a third CTE calledLeague(remove itsORDER BY) and filter it. -
Run: after
ProductSales, add:, League AS ( SELECT TopCategory, ProductCode, ProductName, Units, NetSales, ROW_NUMBER() OVER (PARTITION BY TopCategory ORDER BY NetSales DESC, ProductCode) AS RankInCategory, CAST(100.0 * NetSales / SUM(NetSales) OVER (PARTITION BY TopCategory) AS decimal(5,1)) AS PctOfCategory FROM ProductSales ) SELECT TopCategory, RankInCategory, ProductCode, ProductName, CAST(NetSales AS decimal(12,2)) AS NetSales, PctOfCategory FROM League WHERE RankInCategory <= 3 ORDER BY TopCategory, RankInCategory; -
Observe: 9 rows.
TopCategory RankInCategory ProductCode ProductName NetSales PctOfCategory Accessories 1 NAV-002 Handheld GPS Unit 4021.55 34.3 Accessories 2 BPK-002 Expedition Pack 65L 3721.30 31.7 Accessories 3 BPK-001 Daypack 25L 1652.55 14.1 Camping 1 TNT-002 Summit 4-Person Tent 8777.35 31.8 Camping 2 TNT-003 Ultralight Solo Shelter 5241.45 19.0 Camping 3 SLP-002 Arctic Down Sleeping Bag 4903.60 17.8 Clothing 1 JKT-001 Storm Shell Waterproof Jacket 4866.75 19.9 Clothing 2 FTW-001 Ridge Walking Boot 3996.25 16.4 Clothing 3 JKT-003 Glen Insulated Jacket 3354.90 13.7 -
Why it matters:
PctOfCategorywas computed before the filter, inside the CTE, so it still reflects the whole category. Had you computed it after filtering to the top 3, the percentages would only describe those 3 rows (the trap in worked example 6). -
Confirm: 9 rows; the three Accessories percentages sum to 80.1.
F. Independent practice
Task 1 — Most frequent customers (RANK vs DENSE_RANK)
Rank customers by their number of non-cancelled orders. Return CustomerID, Orders, OrderRank (RANK) and DenseRank (DENSE_RANK), keeping customers whose dense rank is 5 or better. Sort by Orders descending, then CustomerID.
Success criteria: 9 rows. Customers 1, 12 and 18 share rank 1 (27 orders); customers 6 and 26 have OrderRank 4 but DenseRank 2; customer 29 (16 orders) is last with OrderRank 9 and DenseRank 5.
Hint
You can rank directly over GROUP BY results (B10), but you still need a CTE to filter on the rank.
Task 2 — Each customer’s most recent order
Return each ordering customer’s most recent order (any status): CustomerID, OrderID, OrderDate, OrderStatus and SalesChannel. If a customer placed two orders on that date, take the higher OrderID. Sort by OrderDate descending, then CustomerID.
Success criteria: 35 rows. The first is customer 17 (order 1376, 2025-12-31, Delivered, Store); next are customers 15 and 26 (orders 1374 and 1375, 2025-12-30, Pending).
Hint
Customers 6, 12 and 18 each have days with two orders, so a tie-breaker on OrderDate alone is not enough.
Task 3 — Top salesperson in each shop, 2025
For 2025 store orders (non-cancelled), find the salesperson with the highest net sales in each store. Return StoreID, EmployeeID, EmployeeName, Orders and NetSales. Use RANK so that a tie would return both people. Sort by StoreID.
Success criteria: 3 rows — store 1: Sophie Turner (7, 7 orders, 2430.97); store 2: Liam O’Connor (6, 8 orders, 2732.85); store 3: Chen Wei (10, 6 orders, 3468.18).
Hint
Group by store and employee first (one row per pair), then rank within each store. Join HR.Employee for the name.
Task 4 — Clean the price updates (challenging)
Staging.ProductPriceUpdate contains messy product codes (' jkt-005 '), a NULL code and two updates for LGT-001. Return one row per cleaned product code (UPPER(TRIM(ProductCode))), keeping the update with the highest RowID, and ignore rows with no code. Return CleanCode, RowID, NewListPriceText and UpdatesForCode. Sort by CleanCode.
Success criteria: 8 rows — BPK-001, COK-003, JKT-005, LGT-001 (RowID 7, 42.00, 2 updates), NAV-003, SLP-003, TNT-001, TNT-099. Row 6 is discarded as superseded, row 10 as having no code. (Invalid prices and unknown codes are dealt with on Day 15.)
Hint
The same cleaned expression must appear in PARTITION BY and in the output. Filter out NULL codes in WHERE so they never enter the window.
Stretch challenge — Top 5 by units, ties included
For 2025, list the products ranked in the top 5 distinct unit totals within each top-level category. Return TopCategory, UnitRank, ProductCode, ProductName and Units. Sort by TopCategory, UnitRank, ProductCode.
Success criteria: 16 rows: 5 Accessories, 5 Camping and 6 Clothing — BSL-001 and JKT-004 both sold 20 units and share rank 5.
Hint
Which ranking function treats “top 5” as “top 5 values” rather than “top 5 rows”? Reuse the CategoryTree CTE from the lab.
G. Solutions and explanations
Show solution 1
WITH CustomerOrders AS
(
SELECT CustomerID,
COUNT(*) AS Orders,
RANK() OVER (ORDER BY COUNT(*) DESC) AS OrderRank,
DENSE_RANK() OVER (ORDER BY COUNT(*) DESC) AS DenseRank
FROM Sales.SalesOrder
WHERE OrderStatus <> 'Cancelled'
GROUP BY CustomerID
)
SELECT CustomerID, Orders, OrderRank, DenseRank
FROM CustomerOrders
WHERE DenseRank <= 5
ORDER BY Orders DESC, CustomerID;
| CustomerID | Orders | OrderRank | DenseRank |
|---|---|---|---|
| 1 | 27 | 1 | 1 |
| 12 | 27 | 1 | 1 |
| 18 | 27 | 1 | 1 |
| 6 | 21 | 4 | 2 |
| 26 | 21 | 4 | 2 |
| 13 | 19 | 6 | 3 |
| 4 | 18 | 7 | 4 |
| 23 | 18 | 7 | 4 |
| 29 | 16 | 9 | 5 |
Reasoning. Filtering on OrderRank <= 5 instead would return only 5 rows (ranks 1, 1, 1, 4, 4) — the two functions answer different questions. No tie-breaker is needed for RANK or DENSE_RANK; ties are their purpose.
Show solution 2
WITH Ranked AS
(
SELECT CustomerID, OrderID, OrderDate, OrderStatus, SalesChannel,
ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY OrderDate DESC, OrderID DESC) AS rn
FROM Sales.SalesOrder
)
SELECT CustomerID, OrderID, OrderDate, OrderStatus, SalesChannel
FROM Ranked
WHERE rn = 1
ORDER BY OrderDate DESC, CustomerID;
Result: 35 rows, starting:
| CustomerID | OrderID | OrderDate | OrderStatus | SalesChannel |
|---|---|---|---|---|
| 17 | 1376 | 2025-12-31 | Delivered | Store |
| 15 | 1374 | 2025-12-30 | Pending | Online |
| 26 | 1375 | 2025-12-30 | Pending | Online |
| 1 | 1373 | 2025-12-29 | Pending | Online |
| 13 | 1371 | 2025-12-27 | Delivered | Store |
Reasoning. OrderID DESC as the second key makes the choice deterministic. Order IDs increase with time in this dataset, so the higher ID is also the later order on the same day; in a real system you would prefer a timestamp if one existed.
Show solution 3
WITH StaffSales AS
(
SELECT o.StoreID,
o.EmployeeID,
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.SalesChannel = 'Store'
AND o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY o.StoreID, o.EmployeeID
),
Ranked AS
(
SELECT *, RANK() OVER (PARTITION BY StoreID ORDER BY NetSales DESC) AS rk
FROM StaffSales
)
SELECT r.StoreID,
r.EmployeeID,
CONCAT_WS(N' ', e.FirstName, e.LastName) AS EmployeeName,
r.Orders,
CAST(r.NetSales AS decimal(12,2)) AS NetSales
FROM Ranked AS r
JOIN HR.Employee AS e ON e.EmployeeID = r.EmployeeID
WHERE r.rk = 1
ORDER BY r.StoreID, r.EmployeeID;
| StoreID | EmployeeID | EmployeeName | Orders | NetSales |
|---|---|---|---|---|
| 1 | 7 | Sophie Turner | 7 | 2430.97 |
| 2 | 6 | Liam O’Connor | 8 | 2732.85 |
| 3 | 10 | Chen Wei | 6 | 3468.18 |
Reasoning. Staff work across shops, so the same person can rank differently in each. In store 2, Liam (8 orders, 2732.85) only narrowly beats Daniel Okafor (8 orders, 2628.53). ORDER BY r.EmployeeID in the outer query makes the output deterministic if a tie ever produced two rows for a store.
Show solution 4
WITH Cleaned AS
(
SELECT UPPER(TRIM(ProductCode)) AS CleanCode,
RowID,
NewListPriceText,
ROW_NUMBER() OVER (PARTITION BY UPPER(TRIM(ProductCode)) ORDER BY RowID DESC) AS rn,
COUNT(*) OVER (PARTITION BY UPPER(TRIM(ProductCode))) AS UpdatesForCode
FROM Staging.ProductPriceUpdate
WHERE ProductCode IS NOT NULL
)
SELECT CleanCode, RowID, NewListPriceText, UpdatesForCode
FROM Cleaned
WHERE rn = 1
ORDER BY CleanCode;
| CleanCode | RowID | NewListPriceText | UpdatesForCode |
|---|---|---|---|
| BPK-001 | 5 | N/A | 1 |
| COK-003 | 3 | 6.75 | 1 |
| JKT-005 | 9 | 104.00 | 1 |
| LGT-001 | 7 | 42.00 | 2 |
| NAV-003 | 8 | -14.99 | 1 |
| SLP-003 | 2 | 62.50 | 1 |
| TNT-001 | 1 | 195.00 | 1 |
| TNT-099 | 4 | 150.00 | 1 |
Reasoning. “Latest wins” is a common rule, but it should be confirmed: some systems prefer to reject conflicting updates. UPPER is not needed for partitioning in this case-insensitive database, but it gives a consistent displayed code. You can avoid repeating the expression by computing CleanCode in a first CTE and windowing over it in a second. Notice the remaining problems — N/A, a negative price and an unknown product (TNT-099) — which are validation tasks for Day 15.
Show the stretch solution
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
),
Units2025 AS
(
SELECT t.TopCategory, 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
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, p.ProductCode, p.ProductName
),
Ranked AS
(
SELECT *, DENSE_RANK() OVER (PARTITION BY TopCategory ORDER BY Units DESC) AS UnitRank
FROM Units2025
)
SELECT TopCategory, UnitRank, ProductCode, ProductName, Units
FROM Ranked
WHERE UnitRank <= 5
ORDER BY TopCategory, UnitRank, ProductCode;
Result: 16 rows. Clothing: FTW-001 (30), JKT-001 (27), JKT-003 (22), BSL-003 (21), then BSL-001 and JKT-004 (20) both at rank 5. Accessories: BPK-001 25, BPK-003 22, BPK-002 20, NAV-001 19, NAV-003 18. Camping: COK-003 38, TNT-002 26, COK-002 24, TNT-003 22, TNT-001 20.
Reasoning. DENSE_RANK interprets “top 5” as the five highest distinct values. RANK would also include both tied products here (both at rank 5), but if the tie were at rank 4, RANK would jump to 6 and the next product would be excluded. ROW_NUMBER would arbitrarily drop one of the tied products.
H. Knowledge check
-
Understanding. Salaries 90, 80, 80, 70 are ranked in descending order. What do
ROW_NUMBER,RANKandDENSE_RANKreturn for the value 70? -
Debugging. Why does this fail, and how do you fix it?
SELECT EmployeeID, Salary, RANK() OVER (ORDER BY Salary DESC) AS rk FROM HR.Employee WHERE RANK() OVER (ORDER BY Salary DESC) <= 3; -
Query interpretation. A query uses
ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY OrderDate DESC)to pick each customer’s latest order. Run twice, it returns differentOrderIDs for customer 6. Why? -
Understanding.
NTILE(4)over 10 rows: how many rows are in each bucket? -
Debugging. A report shows each product’s percentage of total sales, but after adding
WHERE CategoryID = 2the percentages for tents add up to 100. The analyst expected about 27.6%. Explain.
Show the answers
-
ROW_NUMBER4,RANK4,DENSE_RANK3. -
Window functions are evaluated in the
SELECTphase, afterWHERE, so they cannot appear inWHERE(error 4108). Compute the rank in a CTE or derived table, then filterWHERE rk <= 3in the outer query. -
Customer 6 has dates with two orders. With only
OrderDate DESCin the window’sORDER BY, the two tied rows can be numbered either way. Add a unique tie-breaker, e.g.OrderID DESC. -
3, 3, 2, 2 — 10 ÷ 4 = 2 remainder 2, so the first two buckets get an extra row.
-
WHEREruns before window functions, soSUM(…) OVER ()only totals the tents. Compute the percentage in a CTE or derived table over all products, and filter in the outer query.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Windowed functions can only appear in the SELECT or ORDER BY clauses. (4108) | Window function in WHERE, GROUP BY or HAVING |
Compute it in a CTE/derived table; filter outside |
| The function ‘ROW_NUMBER’ must have an OVER clause with ORDER BY. | Missing ORDER BY in OVER |
Add one; for “any order”, ORDER BY (SELECT NULL) is possible but makes numbering arbitrary |
| Row numbers change between runs | Non-unique window ORDER BY |
Add a unique tie-breaker |
Tied values split across NTILE buckets |
NTILE divides by row count |
Use rank-based rules if ties must be treated equally |
| Shares of total don’t match the full total | A WHERE filter applied before the window |
Window first in a CTE, filter after |
| Column ‘…’ is invalid in the select list… in a window over grouped rows | Window uses a column that is neither grouped nor aggregated | Aggregate it inside: SUM(COUNT(*)) OVER () |
| De-duplication merges records that should differ | Partition key too loose (e.g. all NULL emails together) | Exclude or separately handle NULLs; add more key columns |
Cleanup. Day 5 is read-only; nothing to clean up. Keep RetailLab unchanged for Day 6.
J. Completion checklist, recap and references
Completion checklist
- Worked example 1 shows Tom and Aisha with
RANK4 and different quartiles, and you can explain why. - Worked example 6’s trap made sense: you saw
PctOfTotalchange once the filter moved outside the window. - The guided lab returns the 9-row top-3 table with Accessories percentages summing to 80.1.
- Tasks 1–4 match the success criteria (9, 35, 3 and 8 rows).
- You can state when to use each of the four ranking functions.
Recap
- A window function returns a value for every row;
PARTITION BYrestarts it per group;ORDER BYinsideOVERorders the calculation, not the output. - Window functions run in the
SELECTphase — afterWHERE,GROUP BYandHAVING— so filter on them in an outer query. ROW_NUMBERis unique (add a tie-breaker);RANKshares ranks and leaves gaps;DENSE_RANKshares ranks without gaps;NTILEsplits rows into equal-sized buckets.- Top-N per group and de-duplication are both “rank in a CTE, filter on the rank”.
- Aggregates with
OVERgive group totals and shares next to detail rows; window functions can also rank aggregated (GROUP BY) results.
Next: Day 6 — Window functions part 2: LAG, LEAD, FIRST_VALUE, LAST_VALUE, running totals, moving averages and explicit window frames.