Window functions: ROW_NUMBER, RANK and NTILE
Number and rank rows without losing them: the OVER clause, ROW_NUMBER, RANK and DENSE_RANK with ties, PARTITION BY for ranking inside groups, the top-N-per-group pattern, removing duplicates, and NTILE for bands.
OVER (…) clause. The simplest one numbers the rows in the order you give:
SELECT ROW_NUMBER() OVER (ORDER BY Price DESC, ProductId) AS Pos, Name, PriceFROM dbo.ProductsORDER BY Pos;Pos Name Price --- ------------------- ----- 1 Cordless drill 89.00 2 Masonry paint 10 L 54.00 3 Plywood sheet 18 mm 42.00 4 Claw hammer 18.50 5 Paint roller set 12.50 6 Wood screws (200) 11.90 7 Tape measure 8 m 9.75 8 Cement 25 kg 7.20 9 Timber 2x4 2.4 m 6.80 10 Safety goggles 6.80 11 Wall plugs (100) 4.50 12 Sand 25 kg 4.10
ORDER BY inside OVER says how to number the rows. It is separate from the ORDER BY at the end of the query, which only sorts the result. They are often the same, but they do not have to be.
Ties: ROW_NUMBER, RANK and DENSE_RANK
SELECT Name, Price, ROW_NUMBER() OVER (ORDER BY Price DESC, ProductId) AS RowNum, RANK() OVER (ORDER BY Price DESC) AS Rnk, DENSE_RANK() OVER (ORDER BY Price DESC) AS DenseRnkFROM dbo.ProductsORDER BY Price DESC, ProductId;Name Price RowNum Rnk DenseRnk ------------------- ----- ------ --- -------- Cordless drill 89.00 1 1 1 Masonry paint 10 L 54.00 2 2 2 Plywood sheet 18 mm 42.00 3 3 3 Claw hammer 18.50 4 4 4 Paint roller set 12.50 5 5 5 Wood screws (200) 11.90 6 6 6 Tape measure 8 m 9.75 7 7 7 Cement 25 kg 7.20 8 8 8 Timber 2x4 2.4 m 6.80 9 9 9 Safety goggles 6.80 10 9 9 Wall plugs (100) 4.50 11 11 10 Sand 25 kg 4.10 12 12 11
| Function | On a tie | After a tie |
|---|---|---|
ROW_NUMBER() | ||
RANK() | ||
DENSE_RANK() |
PARTITION BY: start again for every group
PARTITION BY splits the rows into groups, like GROUP BY, but without collapsing them. The numbering restarts at 1 in each group:
SELECT c.Name AS Category, p.Name, p.Price, ROW_NUMBER() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PosInCategoryFROM dbo.Products AS pJOIN dbo.Categories AS c ON c.CategoryId = p.CategoryIdWHERE p.CategoryId <= 4ORDER BY p.CategoryId, PosInCategory;Category Name Price PosInCategory -------- ------------------- ----- ------------- Tools Cordless drill 89.00 1 Tools Claw hammer 18.50 2 Tools Tape measure 8 m 9.75 3 Building Cement 25 kg 7.20 1 Building Sand 25 kg 4.10 2 Timber Plywood sheet 18 mm 42.00 1 Timber Timber 2x4 2.4 m 6.80 2 Fixings Wood screws (200) 11.90 1 Fixings Wall plugs (100) 4.50 2
Top N per group
SELECT CategoryId, Name, PriceFROM dbo.ProductsWHERE ROW_NUMBER() OVER (PARTITION BY CategoryId ORDER BY Price DESC) = 1;Msg 4108, Level 15, State 1, Line 3 Windowed functions can only appear in the SELECT or ORDER BY clauses.
WITH Ranked AS ( SELECT CategoryId, Name, Price, ROW_NUMBER() OVER (PARTITION BY CategoryId ORDER BY Price DESC) AS Pos FROM dbo.Products)SELECT CategoryId, Name, PriceFROM RankedWHERE Pos = 1ORDER BY CategoryId;CategoryId Name Price
---------- ------------------- -----
1 Cordless drill 89.00
2 Cement 25 kg 7.20
3 Plywood sheet 18 mm 42.00
4 Wood screws (200) 11.90
5 Masonry paint 10 L 54.00
6 Safety goggles 6.80Pos = 1 to Pos <= 2 for the top two, and so on. To keep ties in a top N, filter on RANK instead of ROW_NUMBER. The nine dearest products by ROW_NUMBER would drop one of the two 6.80 products at random; RANK keeps both:
WITH Ranked AS ( SELECT Name, Price, RANK() OVER (ORDER BY Price DESC) AS Rnk FROM dbo.Products)SELECT Rnk, Name, PriceFROM RankedWHERE Rnk <= 9ORDER BY Rnk, Name;Rnk Name Price --- ------------------- ----- 1 Cordless drill 89.00 2 Masonry paint 10 L 54.00 3 Plywood sheet 18 mm 42.00 4 Claw hammer 18.50 5 Paint roller set 12.50 6 Wood screws (200) 11.90 7 Tape measure 8 m 9.75 8 Cement 25 kg 7.20 9 Safety goggles 6.80 9 Timber 2x4 2.4 m 6.80
Removing duplicates
CREATE TABLE #Signups (SignupId int, Email nvarchar(100), SignedUp date);INSERT INTO #Signups VALUES (1, N'asha@example.com', '2024-01-05'), (2, N'ben@example.com', '2024-01-09'), (3, N'asha@example.com', '2024-02-11'), (4, N'chloe@example.com', '2024-02-20'), (5, N'asha@example.com', '2024-03-02'), (6, N'ben@example.com', '2024-03-15'); WITH Numbered AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY Email ORDER BY SignedUp DESC) AS n FROM #Signups)DELETE FROM Numbered WHERE n > 1; -- keeps each email's latest signup SELECT * FROM #Signups ORDER BY SignupId;SignupId Email SignedUp
-------- ----------------- ----------
4 chloe@example.com 2024-02-20
5 asha@example.com 2024-03-02
6 ben@example.com 2024-03-15NTILE: split into bands
NTILE(n) deals the ordered rows into n groups of (nearly) equal size and returns the group number. 12 products into 4 price bands gives 3 per band:
SELECT NTILE(4) OVER (ORDER BY Price DESC, ProductId) AS Band, Name, PriceFROM dbo.ProductsORDER BY Band, Price DESC;Band Name Price ---- ------------------- ----- 1 Cordless drill 89.00 1 Masonry paint 10 L 54.00 1 Plywood sheet 18 mm 42.00 2 Claw hammer 18.50 2 Paint roller set 12.50 2 Wood screws (200) 11.90 3 Tape measure 8 m 9.75 3 Cement 25 kg 7.20 3 Timber 2x4 2.4 m 6.80 4 Safety goggles 6.80 4 Wall plugs (100) 4.50 4 Sand 25 kg 4.10
SELECT Band, COUNT(*) AS Products, MIN(Price) AS Lowest, MAX(Price) AS HighestFROM ( SELECT NTILE(5) OVER (ORDER BY Price DESC, ProductId) AS Band, Price FROM dbo.Products) AS bGROUP BY BandORDER BY Band;Band Products Lowest Highest ---- -------- ------ ------- 1 3 42.00 89.00 2 3 11.90 18.50 3 2 7.20 9.75 4 2 6.80 6.80 5 2 4.10 4.50
Show a hint
Show the solution
WITH Latest AS ( SELECT CustomerId, OrderId, OrderDate, ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderDate DESC) AS n FROM dbo.Orders)SELECT c.FirstName, l.OrderId, l.OrderDateFROM Latest AS lJOIN dbo.Customers AS c ON c.CustomerId = l.CustomerIdWHERE l.n = 1ORDER BY c.FirstName;FirstName OrderId OrderDate --------- ------- ---------- Asha 3 2024-03-01 Ben 9 2024-06-01 Chloe 6 2024-04-20 Dev 7 2024-05-05 Farid 10 2024-06-12 Zoë 8 2024-05-18
Remember
A window function computes over a set of rows and keeps every row: you know it by OVER (…). ROW_NUMBER numbers 1, 2, 3; RANK repeats on ties and skips; DENSE_RANK repeats without gaps. Add a unique tiebreaker to the ORDER BY in OVER when ROW_NUMBER must be repeatable. PARTITION BY restarts the window for each group without collapsing rows. Window functions cannot go in WHERE: compute them in a CTE and filter outside. Top N per group: ROW_NUMBER (or RANK to keep ties) in a CTE, then WHERE Pos <= N. Delete duplicates by numbering inside each group and deleting n > 1 through the CTE. NTILE(n) deals rows into n bands; the first bands get the extra rows.