Kodewick
Lesson 17 of 21Contents

Window functions

Lesson 17 of 21 · Part 4 · Stronger queries · about 20 minutes

GROUP BY squeezes each group into one row. For example, how many products each category has:

main.sql
1SELECT category, COUNT(*) AS products2FROM products3GROUP BY category4ORDER BY category;
Output
categoryproducts
Bags2
Electronics4
Kitchen2
Stationery2
4 rows

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:

main.sql
1SELECT name, price,2    ROUND(AVG(price) OVER (), 2) AS avg_price,3    price - ROUND(AVG(price) OVER (), 2) AS diff4FROM products5ORDER BY id;
Output
namepriceavg_pricediff
Notebook4.5019.08-14.58
Pens (10)6.0019.08-13.08
Backpack39.9019.0820.82
Water bottle12.0019.08-7.08
Headphones59.0019.0839.92
Desk lamp25.0019.085.92
USB cable8.5019.08-10.58
Coffee mug9.9019.08-9.18
Phone case15.0019.08-4.08
Tote bag11.0019.08-8.08
10 rows

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 …):

main.sql
1SELECT name, price,2    ROW_NUMBER() OVER (ORDER BY price DESC) AS n3FROM products4ORDER BY n;
Output
namepricen
Headphones59.001
Backpack39.902
Desk lamp25.003
Phone case15.004
Water bottle12.005
Tote bag11.006
Coffee mug9.907
USB cable8.508
Pens (10)6.009
Notebook4.5010
10 rows

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.

Guess first

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;

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:

main.sql
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;
Output
customer_idordersplacedense_place
1311
2222
4222
5222
3153
7153
8153
7 rows

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:

main.sql
1SELECT name, category, price,2    ROUND(AVG(price) OVER (PARTITION BY category), 2) AS category_avg3FROM products4ORDER BY category, price;
Output
namecategorypricecategory_avg
Tote bagBags11.0025.45
BackpackBags39.9025.45
USB cableElectronics8.5026.88
Phone caseElectronics15.0026.88
Desk lampElectronics25.0026.88
HeadphonesElectronics59.0026.88
Coffee mugKitchen9.9010.95
Water bottleKitchen12.0010.95
NotebookStationery4.505.25
Pens (10)Stationery6.005.25
10 rows

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:

main.sql
1SELECT category, name, price,2    ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS n3FROM products4ORDER BY category, n;
Output
categorynamepricen
BagsBackpack39.901
BagsTote bag11.002
ElectronicsHeadphones59.001
ElectronicsDesk lamp25.002
ElectronicsPhone case15.003
ElectronicsUSB cable8.504
KitchenWater bottle12.001
KitchenCoffee mug9.902
StationeryPens (10)6.001
StationeryNotebook4.502
10 rows

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:

main.sql
1SELECT category, name, price2FROM products3WHERE ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) = 1;
Output
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:

main.sql
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;
Output
categorynameprice
BagsBackpack39.90
ElectronicsHeadphones59.00
KitchenWater bottle12.00
StationeryPens (10)6.00
4 rows

"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:

main.sql
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;
Output
idorder_datetotalrunning_total
12025-09-0219.5019.50
22025-09-0539.9059.40
32025-09-1876.00135.40
52025-10-0143.80179.20
62025-10-0322.50201.70
72025-10-1061.90263.60
82025-10-1259.00322.60
92025-10-1521.00343.60
102025-10-2023.50367.10
112025-10-2212.00379.10
122025-10-2539.60418.70
11 rows

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.

Exercise 1

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.

It should print
namesalaryn
Grace Lee60001
Omar Haddad45002
Lucia Romero42003
Mei Tanaka33004
Tom Becker32005
Sam Wilson28006
6 rows
exercise1.sql
1SELECT name, salary2FROM employees;
Output
Press Run or Ctrl+Enter
Exercise 2

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.

It should print
categorynamepricen
BagsTote bag11.001
BagsBackpack39.902
ElectronicsUSB cable8.501
ElectronicsPhone case15.002
ElectronicsDesk lamp25.003
ElectronicsHeadphones59.004
KitchenCoffee mug9.901
KitchenWater bottle12.002
StationeryNotebook4.501
StationeryPens (10)6.002
10 rows
exercise2.sql
1SELECT category, name, price2FROM products3ORDER BY category;
Output
Press Run or Ctrl+Enter
Exercise 3

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.

It should print
customer_idorder_datenth
12025-09-021
12025-09-182
12025-10-153
22025-09-051
22025-10-102
32025-10-031
42025-09-211
42025-10-252
52025-10-011
52025-10-222
72025-10-121
82025-10-201
12 rows
exercise3.sql
1SELECT customer_id, order_date2FROM orders;
Output
Press Run or Ctrl+Enter

What you learned

  • A window function writes a worked-out value next to every row, and the number of rows stays the same; GROUP BY squeezes 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 BY inside OVER decides the numbering, the last ORDER BY decides how the result is shown; write both.
  • A window function can't go in WHERE: work it out first with WITH, then filter outside.
  • SUM() OVER (ORDER BY …) keeps a running total.