Window functions
GROUP BY squeezes each group into one row. For example, how many products each category has:
1SELECT category, COUNT(*) AS products2FROM products3GROUP BY category4ORDER BY category;
| category | products |
|---|---|
| Bags | 2 |
| Electronics | 4 |
| Kitchen | 2 |
| Stationery | 2 |
Ten products became 4 rows, and each product's own name and price are gone. Sometimes we want both: every product still on its own row, with its place in the whole table or its category's average price written next to it. That's what window functions do.
OVER: no rows disappear
Put OVER () after an aggregate function and it no longer squeezes the rows into one. Instead, it writes the value it works out next to every row:
1SELECT name, price,2 ROUND(AVG(price) OVER (), 2) AS avg_price,3 price - ROUND(AVG(price) OVER (), 2) AS diff4FROM products5ORDER BY id;
| name | price | avg_price | diff |
|---|---|---|---|
| Notebook | 4.50 | 19.08 | -14.58 |
| Pens (10) | 6.00 | 19.08 | -13.08 |
| Backpack | 39.90 | 19.08 | 20.82 |
| Water bottle | 12.00 | 19.08 | -7.08 |
| Headphones | 59.00 | 19.08 | 39.92 |
| Desk lamp | 25.00 | 19.08 | 5.92 |
| USB cable | 8.50 | 19.08 | -10.58 |
| Coffee mug | 9.90 | 19.08 | -9.18 |
| Phone case | 15.00 | 19.08 | -4.08 |
| Tote bag | 11.00 | 19.08 | -8.08 |
Still 10 rows. Nothing inside OVER () means "use all the rows together": every row sees the average price of all products, 19.08, so you can tell at a glance which ones cost more than average, and by how much.
The range inside the parentheses of OVER is called the window: the rows a row can "see" while its value is worked out.
ROW_NUMBER: giving places
ROW_NUMBER() gives each row a number: 1, 2, 3… The order to number them in goes inside OVER (ORDER BY …):
1SELECT name, price,2 ROW_NUMBER() OVER (ORDER BY price DESC) AS n3FROM products4ORDER BY n;
| name | price | n |
|---|---|---|
| Headphones | 59.00 | 1 |
| Backpack | 39.90 | 2 |
| Desk lamp | 25.00 | 3 |
| Phone case | 15.00 | 4 |
| Water bottle | 12.00 | 5 |
| Tote bag | 11.00 | 6 |
| Coffee mug | 9.90 | 7 |
| USB cable | 8.50 | 8 |
| Pens (10) | 6.00 | 9 |
| Notebook | 4.50 | 10 |
The most expensive one is number 1. Notice the two ORDER BYs: the one inside OVER decides how rows are numbered, the last one decides the order the result is shown in. They can differ, say numbering by price but showing by id.
Window functions are worked out after WHERE, at SELECT time: the unwanted rows go first, and what's left gets numbered.
What is the largest n in the result?
1SELECT name,2 ROW_NUMBER() OVER (ORDER BY price DESC) AS n3FROM products4WHERE category = 'Electronics'5ORDER BY n;
WHERE keeps the 4 electronics products first. The window function sees only those 4 rows, so they are numbered 1 to 4.
Ties: RANK and DENSE_RANK
What if two rows have the same value? ROW_NUMBER() still gives them different numbers, and which comes first is anyone's guess. To give equal values the same place, use RANK() or DENSE_RANK(). Here is how many orders each customer placed:
1WITH counts AS (2 SELECT customer_id, COUNT(*) AS orders3 FROM orders4 GROUP BY customer_id5)6SELECT customer_id, orders,7 RANK() OVER (ORDER BY orders DESC) AS place,8 DENSE_RANK() OVER (ORDER BY orders DESC) AS dense_place9FROM counts10ORDER BY orders DESC, customer_id;
| customer_id | orders | place | dense_place |
|---|---|---|---|
| 1 | 3 | 1 | 1 |
| 2 | 2 | 2 | 2 |
| 4 | 2 | 2 | 2 |
| 5 | 2 | 2 | 2 |
| 3 | 1 | 5 | 3 |
| 7 | 1 | 5 | 3 |
| 8 | 1 | 5 | 3 |
Three customers placed 2 orders and share 2nd place. The difference is the next one: RANK() skips the places they took up, so the next is 5th; DENSE_RANK() doesn't skip, so the next is 3rd.
Note: You can't call the alias rank. In MySQL 8 RANK is a reserved word, and AS rank gives the syntax error ERROR 1064. Pick another name, like place.
PARTITION BY: each group on its own
OVER (PARTITION BY category) splits the rows into blocks by category, and each block is worked out on its own. Below, next to each product is the average price of its own category:
1SELECT name, category, price,2 ROUND(AVG(price) OVER (PARTITION BY category), 2) AS category_avg3FROM products4ORDER BY category, price;
| name | category | price | category_avg |
|---|---|---|---|
| Tote bag | Bags | 11.00 | 25.45 |
| Backpack | Bags | 39.90 | 25.45 |
| USB cable | Electronics | 8.50 | 26.88 |
| Phone case | Electronics | 15.00 | 26.88 |
| Desk lamp | Electronics | 25.00 | 26.88 |
| Headphones | Electronics | 59.00 | 26.88 |
| Coffee mug | Kitchen | 9.90 | 10.95 |
| Water bottle | Kitchen | 12.00 | 10.95 |
| Notebook | Stationery | 4.50 | 5.25 |
| Pens (10) | Stationery | 6.00 | 5.25 |
Compare it with the GROUP BY at the top: the groups are the same, but there are still 10 rows.
PARTITION BY and ORDER BY can go together: a ranking inside each category, with every block starting again from 1:
1SELECT category, name, price,2 ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS n3FROM products4ORDER BY category, n;
| category | name | price | n |
|---|---|---|---|
| Bags | Backpack | 39.90 | 1 |
| Bags | Tote bag | 11.00 | 2 |
| Electronics | Headphones | 59.00 | 1 |
| Electronics | Desk lamp | 25.00 | 2 |
| Electronics | Phone case | 15.00 | 3 |
| Electronics | USB cable | 8.50 | 4 |
| Kitchen | Water bottle | 12.00 | 1 |
| Kitchen | Coffee mug | 9.90 | 2 |
| Stationery | Pens (10) | 6.00 | 1 |
| Stationery | Notebook | 4.50 | 2 |
The top of each group
For the most expensive product in each category, keep only the rows where n = 1. Putting it straight into WHERE doesn't work:
1SELECT category, name, price2FROM products3WHERE ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) = 1;
ERROR 3593 (HY000): You cannot use the window function 'row_number' in this context.'
WHERE runs before window functions (lesson 10 showed the order things run in), so there are no places yet. The way out: work the places out first with WITH, then filter outside:
1WITH ranked AS (2 SELECT category, name, price,3 ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS n4 FROM products5)6SELECT category, name, price7FROM ranked8WHERE n = 19ORDER BY category;
| category | name | price |
|---|---|---|
| Bags | Backpack | 39.90 |
| Electronics | Headphones | 59.00 |
| Kitchen | Water bottle | 12.00 |
| Stationery | Pens (10) | 6.00 |
"Number first, then take number 1" comes up all the time: each customer's first order, each month's best-selling product. They're all found this way.
Running totals: SUM() OVER
SUM() with OVER (ORDER BY …) adds up from the first row to this one: a running total. Below is each order's amount (cancelled ones left out), and how much was sold up to and including that order:
1WITH totals AS (2 SELECT o.id, o.order_date, SUM(oi.quantity * oi.unit_price) AS total3 FROM orders AS o4 JOIN order_items AS oi ON oi.order_id = o.id5 WHERE o.status <> 'cancelled'6 GROUP BY o.id, o.order_date7)8SELECT id, order_date, total,9 SUM(total) OVER (ORDER BY order_date) AS running_total10FROM totals11ORDER BY order_date;
| id | order_date | total | running_total |
|---|---|---|---|
| 1 | 2025-09-02 | 19.50 | 19.50 |
| 2 | 2025-09-05 | 39.90 | 59.40 |
| 3 | 2025-09-18 | 76.00 | 135.40 |
| 5 | 2025-10-01 | 43.80 | 179.20 |
| 6 | 2025-10-03 | 22.50 | 201.70 |
| 7 | 2025-10-10 | 61.90 | 263.60 |
| 8 | 2025-10-12 | 59.00 | 322.60 |
| 9 | 2025-10-15 | 21.00 | 343.60 |
| 10 | 2025-10-20 | 23.50 | 367.10 |
| 11 | 2025-10-22 | 12.00 | 379.10 |
| 12 | 2025-10-25 | 39.60 | 418.70 |
The running_total on the last row is the total of all orders. Take the ORDER BY out of OVER, and every row shows that total.
Window functions came in MySQL 8 (the old 5.7 doesn't have them); PostgreSQL, SQL Server and SQLite have them too, written the same way.
Practice
Write the query, then press Check. A hint costs nothing; the answer is there when you need it. Don't forget the ORDER BY inside OVER.
Salary ranking
✓Number the employees by salary, highest first: get name, salary and the number n (use ROW_NUMBER()), with the result ordered by n.
| name | salary | n |
|---|---|---|
| Grace Lee | 6000 | 1 |
| Omar Haddad | 4500 | 2 |
| Lucia Romero | 4200 | 3 |
| Mei Tanaka | 3300 | 4 |
| Tom Becker | 3200 | 5 |
| Sam Wilson | 2800 | 6 |
1SELECT name, salary2FROM employees;
Cheapest in each category
✓Within each category, number the products by price, lowest first: get category, name, price and the number n, with the result ordered by category, then n.
| category | name | price | n |
|---|---|---|---|
| Bags | Tote bag | 11.00 | 1 |
| Bags | Backpack | 39.90 | 2 |
| Electronics | USB cable | 8.50 | 1 |
| Electronics | Phone case | 15.00 | 2 |
| Electronics | Desk lamp | 25.00 | 3 |
| Electronics | Headphones | 59.00 | 4 |
| Kitchen | Coffee mug | 9.90 | 1 |
| Kitchen | Water bottle | 12.00 | 2 |
| Stationery | Notebook | 4.50 | 1 |
| Stationery | Pens (10) | 6.00 | 2 |
1SELECT category, name, price2FROM products3ORDER BY category;
Which order is it?
✓Number each customer's orders by date, the first one being 1: get customer_id, order_date and the number nth, with the result ordered by customer_id, then nth.
| customer_id | order_date | nth |
|---|---|---|
| 1 | 2025-09-02 | 1 |
| 1 | 2025-09-18 | 2 |
| 1 | 2025-10-15 | 3 |
| 2 | 2025-09-05 | 1 |
| 2 | 2025-10-10 | 2 |
| 3 | 2025-10-03 | 1 |
| 4 | 2025-09-21 | 1 |
| 4 | 2025-10-25 | 2 |
| 5 | 2025-10-01 | 1 |
| 5 | 2025-10-22 | 2 |
| 7 | 2025-10-12 | 1 |
| 8 | 2025-10-20 | 1 |
1SELECT customer_id, order_date2FROM orders;
What you learned
- A window function writes a worked-out value next to every row, and the number of rows stays the same;
GROUP BYsqueezes a group into one row. OVER ()uses all the rows together;OVER (PARTITION BY column)works each group out on its own.ROW_NUMBER()numbers rows;RANK()gives ties the same place and skips after them;DENSE_RANK()gives ties the same place without skipping.- The
ORDER BYinsideOVERdecides the numbering, the lastORDER BYdecides how the result is shown; write both. - A window function can't go in
WHERE: work it out first withWITH, then filter outside. SUM() OVER (ORDER BY …)keeps a running total.