Window functions
GROUP BY squeezes each group into one row. You get the total, and you lose the rows. A window function works out a figure across a group of rows and keeps every row. With one, you can show each employee's salary beside the average of their department, number the rows of each group, or keep a running total. Problems that need awkward subqueries become one line.
Window functions are the last big idea in this course, and the one that most separates a comfortable SQL user from a beginner. All the major databases support them: PostgreSQL, SQL Server and Oracle for many years, MySQL from version 8, SQLite from version 3.25.
OVER makes a function a window function
Compare these. First, GROUP BY: one row per department.
SELECT department, ROUND(AVG(salary)) AS avg_salary
FROM employees
GROUP BY department;Result
| department | avg_salary |
|---|---|
| Management | 95000.0 |
| Sales | 52000.0 |
| Support | 34500.0 |
| Warehouse | 36000.0 |
4 rows
Now the window version: every employee, with the department's average alongside.
SELECT name, department, salary,
ROUND(AVG(salary) OVER (PARTITION BY department)) AS dept_average
FROM employees
ORDER BY department, salary DESC;Result
| name | department | salary | dept_average |
|---|---|---|---|
| Anita Kapoor | Management | 95000 | 95000.0 |
| Rohit Bansal | Sales | 60000 | 52000.0 |
| Deepa Iyer | Sales | 48000 | 52000.0 |
| Manoj Yadav | Sales | 48000 | 52000.0 |
| Harpreet Kaur | Support | 36000 | 34500.0 |
| Nikhil Jain | Support | 33000 | 34500.0 |
| Farah Sheikh | Warehouse | 42000 | 36000.0 |
| Gopal Reddy | Warehouse | 30000 | 36000.0 |
8 rows
The word OVER is what changes an aggregate into a window function. Inside its brackets, PARTITION BY department says "work this out separately for each department". A partition is like a group, except that the rows are not merged.
With that, comparing a row with its group is plain arithmetic.
SELECT name, department, salary,
salary - ROUND(AVG(salary) OVER (PARTITION BY department)) AS above_or_below
FROM employees
ORDER BY department, salary DESC;Result
| name | department | salary | above_or_below |
|---|---|---|---|
| Anita Kapoor | Management | 95000 | 0.0 |
| Rohit Bansal | Sales | 60000 | 8000.0 |
| Deepa Iyer | Sales | 48000 | -4000.0 |
| Manoj Yadav | Sales | 48000 | -4000.0 |
| Harpreet Kaur | Support | 36000 | 1500.0 |
| Nikhil Jain | Support | 33000 | -1500.0 |
| Farah Sheikh | Warehouse | 42000 | 6000.0 |
| Gopal Reddy | Warehouse | 30000 | -6000.0 |
8 rows
An empty OVER: the whole result
Leave the brackets empty and the window is all the rows.
SELECT name, price,
SUM(price) OVER () AS total_of_all,
ROUND(100.0 * price / SUM(price) OVER (), 1) AS percent
FROM products
ORDER BY price DESC
LIMIT 5;Result
| name | price | total_of_all | percent |
|---|---|---|---|
| Pressure Cooker 3 L | 1450 | 7710 | 18.8 |
| Power Bank | 1299 | 7710 | 16.8 |
| Bedsheet Double | 899 | 7710 | 11.7 |
| School Bag | 750 | 7710 | 9.7 |
| Basmati Rice 5 kg | 650 | 7710 | 8.4 |
5 rows
In the lesson on subqueries this needed a subquery for the total. Here it does not.
Numbering rows: ROW_NUMBER
ROW_NUMBER() numbers the rows 1, 2, 3 and so on. It needs to know the order to number them in, so an ORDER BY goes inside the OVER.
SELECT ROW_NUMBER() OVER (ORDER BY price DESC) AS position, name, price
FROM products
LIMIT 5;Result
| position | name | price |
|---|---|---|
| 1 | Pressure Cooker 3 L | 1450 |
| 2 | Power Bank | 1299 |
| 3 | Bedsheet Double | 899 |
| 4 | School Bag | 750 |
| 5 | Basmati Rice 5 kg | 650 |
5 rows
Add PARTITION BY and the numbering starts again at 1 for each group.
SELECT category, name, price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS position
FROM products
ORDER BY category, position;Result
| category | name | price | position |
|---|---|---|---|
| Electronics | Power Bank | 1299 | 1 |
| Electronics | Earphones | 499 | 2 |
| Electronics | Extension Board | 399 | 3 |
| Electronics | LED Bulb 9 W | 110 | 4 |
| Grocery | Basmati Rice 5 kg | 650 | 1 |
| Grocery | Tea 500 g | 280 | 2 |
| Grocery | Toor Dal 1 kg | 160 | 3 |
| Grocery | Sunflower Oil 1 L | 145 | 4 |
| Home | Pressure Cooker 3 L | 1450 | 1 |
| Home | Bedsheet Double | 899 | 2 |
| Home | Steel Bottle 1 L | 450 | 3 |
| Home | Wall Clock | 349 | 4 |
16 rows
Showing the first 12 of 16 rows.
Top N in each group
"The two dearest products in each category" is one of the most asked questions in SQL, in work and in interviews. LIMIT cannot do it, because it cuts the whole result, not each group. Number the rows inside each group, then keep the low numbers.
There is one catch: a window function cannot be used in WHERE, because WHERE runs before window functions are worked out. So the numbering goes in a WITH step, and the filter in the main query.
WITH ranked AS (
SELECT category, name, price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS position
FROM products
)
SELECT category, name, price
FROM ranked
WHERE position <= 2
ORDER BY category, price DESC;Result
| category | name | price |
|---|---|---|
| Electronics | Power Bank | 1299 |
| Electronics | Earphones | 499 |
| Grocery | Basmati Rice 5 kg | 650 |
| Grocery | Tea 500 g | 280 |
| Home | Pressure Cooker 3 L | 1450 |
| Home | Bedsheet Double | 899 |
| Stationery | School Bag | 750 |
| Stationery | Geometry Box | 120 |
8 rows
The same pattern gives each customer's most recent order.
WITH numbered AS (
SELECT o.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
FROM orders o
)
SELECT c.name, n.id AS order_id, n.order_date, n.status
FROM numbered n
JOIN customers c ON c.id = n.customer_id
WHERE n.rn = 1
ORDER BY c.name;Result
| name | order_id | order_date | status |
|---|---|---|---|
| Asha Verma | 20 | 2026-10-04 | pending |
| Imran Ali | 15 | 2026-06-21 | delivered |
| Joy Thomas | 14 | 2026-06-02 | delivered |
| Kabir Khan | 18 | 2026-09-01 | pending |
| Neha Joshi | 19 | 2026-09-17 | pending |
| Priya Nair | 17 | 2026-08-11 | shipped |
| Ravi Kumar | 16 | 2026-07-09 | delivered |
| Sunita Rao | 13 | 2026-05-19 | shipped |
| Vikram Das | 11 | 2026-04-18 | delivered |
9 rows
Keep this pattern. You will use it for the rest of your working life.
Ranking with ties: RANK and DENSE_RANK
ROW_NUMBER gives every row a different number, even when values tie. Two functions respect ties.
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_number,
RANK() OVER (ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;Result
| name | salary | row_number | rank | dense_rank |
|---|---|---|---|---|
| Anita Kapoor | 95000 | 1 | 1 | 1 |
| Rohit Bansal | 60000 | 2 | 2 | 2 |
| Deepa Iyer | 48000 | 3 | 3 | 3 |
| Manoj Yadav | 48000 | 4 | 3 | 3 |
| Farah Sheikh | 42000 | 5 | 5 | 4 |
| Harpreet Kaur | 36000 | 6 | 6 | 5 |
| Nikhil Jain | 33000 | 7 | 7 | 6 |
| Gopal Reddy | 30000 | 8 | 8 | 7 |
8 rows
Look at the two people on the same salary.
| Function | Tied rows | After a tie |
|---|---|---|
ROW_NUMBER | get different numbers, in no particular order | carries on |
RANK | share a number | skips: 1, 2, 3, 3, 5 |
DENSE_RANK | share a number | does not skip: 1, 2, 3, 3, 4 |
RANK works like a sports table: two people share third place and the next is fifth. DENSE_RANK leaves no gaps.
With DENSE_RANK, the "second highest salary" question has a clean answer that also handles ties and extends to third, fourth or any place.
WITH r AS (
SELECT name, salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS place
FROM employees
)
SELECT name, salary FROM r WHERE place = 2;Result
| name | salary |
|---|---|
| Rohit Bansal | 60000 |
1 row
Running totals
Put an ORDER BY inside the OVER of a SUM, and the sum becomes cumulative: each row shows the total of everything up to and including itself.
WITH totals AS (
SELECT o.id, o.order_date, SUM(oi.quantity * p.price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'delivered'
GROUP BY o.id, o.order_date
)
SELECT id, order_date, total,
SUM(total) OVER (ORDER BY order_date, id) AS running_total
FROM totals
ORDER BY order_date, id;Result
| id | order_date | total | running_total |
|---|---|---|---|
| 1 | 2026-01-05 | 1700 | 1700 |
| 2 | 2026-01-12 | 839 | 2539 |
| 4 | 2026-02-02 | 570 | 3109 |
| 5 | 2026-02-14 | 990 | 4099 |
| 6 | 2026-02-28 | 1450 | 5549 |
| 7 | 2026-03-03 | 900 | 6449 |
| 8 | 2026-03-15 | 719 | 7168 |
| 10 | 2026-04-01 | 1050 | 8218 |
| 11 | 2026-04-18 | 1115 | 9333 |
| 12 | 2026-05-06 | 1798 | 11131 |
| 14 | 2026-06-02 | 1458 | 12589 |
| 15 | 2026-06-21 | 690 | 13279 |
13 rows
Showing the first 12 of 13 rows.
Add PARTITION BY for a running total that restarts for each customer, each month or each account. This is how a bank statement shows a balance after every line.
A moving average smooths out ups and downs. The extra words say which rows around the current one to include: here, this row and the two before it.
WITH totals AS (
SELECT o.id, o.order_date, SUM(oi.quantity * p.price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'delivered'
GROUP BY o.id, o.order_date
)
SELECT id, order_date, total,
ROUND(AVG(total) OVER (ORDER BY order_date, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)) AS average_of_last_3
FROM totals
ORDER BY order_date, id
LIMIT 8;Result
| id | order_date | total | average_of_last_3 |
|---|---|---|---|
| 1 | 2026-01-05 | 1700 | 1700.0 |
| 2 | 2026-01-12 | 839 | 1270.0 |
| 4 | 2026-02-02 | 570 | 1036.0 |
| 5 | 2026-02-14 | 990 | 800.0 |
| 6 | 2026-02-28 | 1450 | 1003.0 |
| 7 | 2026-03-03 | 900 | 1113.0 |
| 8 | 2026-03-15 | 719 | 1023.0 |
| 10 | 2026-04-01 | 1050 | 890.0 |
8 rows
Looking at the row before or after: LAG and LEAD
LAG fetches a value from the previous row. LEAD fetches from the next. They answer "how does this compare with last time?".
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
FROM orders
GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, orders,
LAG(orders) OVER (ORDER BY month) AS previous_month,
orders - LAG(orders) OVER (ORDER BY month) AS change
FROM monthly
ORDER BY month;Result
| month | orders | previous_month | change |
|---|---|---|---|
| 2026-01 | 3 | NULL | NULL |
| 2026-02 | 3 | 3 | 0 |
| 2026-03 | 3 | 3 | 0 |
| 2026-04 | 2 | 3 | -1 |
| 2026-05 | 2 | 2 | 0 |
| 2026-06 | 2 | 2 | 0 |
| 2026-07 | 1 | 2 | -1 |
| 2026-08 | 1 | 1 | 0 |
| 2026-09 | 2 | 1 | 1 |
| 2026-10 | 1 | 2 | -1 |
10 rows
The first row has no previous row, so LAG gives NULL there.
How many days passed between each customer's orders?
SELECT customer_id, order_date,
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS previous_order,
CAST(julianday(order_date)
- julianday(LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS INTEGER) AS days_since
FROM orders
WHERE customer_id IN (1, 2)
ORDER BY customer_id, order_date;Result
| customer_id | order_date | previous_order | days_since |
|---|---|---|---|
| 1 | 2026-01-05 | NULL | NULL |
| 1 | 2026-02-02 | 2026-01-05 | 28 |
| 1 | 2026-05-06 | 2026-02-02 | 93 |
| 1 | 2026-10-04 | 2026-05-06 | 151 |
| 2 | 2026-01-12 | NULL | NULL |
| 2 | 2026-03-03 | 2026-01-12 | 50 |
| 2 | 2026-07-09 | 2026-03-03 | 128 |
7 rows
The parts of OVER
function() OVER (
PARTITION BY columns -- split the rows into groups (optional)
ORDER BY columns -- the order inside each group (needed for numbering, running totals, LAG)
ROWS BETWEEN ... AND ... -- which neighbouring rows to include (optional)
)GROUP BY or a window function?
GROUP BY | Window function | |
|---|---|---|
| Rows in the result | one per group | every original row |
| Can show row details | no | yes |
| Typical question | "total per category" | "each product and its share of its category" |
They can be combined. A query may group first and then apply window functions to the grouped rows, as the running total above does.
Common mistakes
- Using a window function in
WHERE. Put it in aWITHstep or a subquery and filter outside. - Forgetting
ORDER BYinsideOVERforROW_NUMBER,RANKorLAG. Without it, the numbering has no defined order. - Confusing the
ORDER BYinsideOVER, which controls the calculation, with theORDER BYat the end of the query, which controls how the result is displayed. You usually want both. - Expecting
ROW_NUMBERto treat ties fairly. UseRANKorDENSE_RANKfor that.
Try it yourself
Exercise 1. Show each product's name, category and price, together with the highest price in its category as category_max. Sort by product id.
Show one answer
SELECT name, category, price, MAX(price) OVER (PARTITION BY category) AS category_max FROM products ORDER BY id;| name | category | price | category_max |
|---|---|---|---|
| Basmati Rice 5 kg | Grocery | 650 | 650 |
| Toor Dal 1 kg | Grocery | 160 | 650 |
| Sunflower Oil 1 L | Grocery | 145 | 650 |
| Tea 500 g | Grocery | 280 | 650 |
| Notebook 200 pages | Stationery | 60 | 750 |
| Ball Pen pack of 10 | Stationery | 90 | 750 |
| Geometry Box | Stationery | 120 | 750 |
| School Bag | Stationery | 750 | 750 |
| LED Bulb 9 W | Electronics | 110 | 1299 |
| Extension Board | Electronics | 399 | 1299 |
| Earphones | Electronics | 499 | 1299 |
| Power Bank | Electronics | 1299 | 1299 |
16 rows
Showing the first 12 of 16 rows.
Exercise 2. Rank the products by stock, highest first, using RANK(). Show the name, the stock and the rank, sorted by rank and then name.
Show one answer
SELECT name, stock, RANK() OVER (ORDER BY stock DESC) AS r FROM products ORDER BY r, name;| name | stock | r |
|---|---|---|
| Notebook 200 pages | 300 | 1 |
| LED Bulb 9 W | 200 | 2 |
| Ball Pen pack of 10 | 150 | 3 |
| Toor Dal 1 kg | 75 | 4 |
| Sunflower Oil 1 L | 60 | 5 |
| Steel Bottle 1 L | 55 | 6 |
| Geometry Box | 45 | 7 |
| Basmati Rice 5 kg | 40 | 8 |
| Extension Board | 35 | 9 |
| Wall Clock | 30 | 10 |
| Bedsheet Double | 25 | 11 |
| School Bag | 20 | 12 |
16 rows
Showing the first 12 of 16 rows.
Exercise 3. Show the name, department and salary of the highest-paid employee in each department, sorted by department. Use ROW_NUMBER() in a WITH step.
Show one answer
WITH x AS (SELECT name, department, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn FROM employees) SELECT name, department, salary FROM x WHERE rn = 1 ORDER BY department;| name | department | salary |
|---|---|---|
| Anita Kapoor | Management | 95000 |
| Rohit Bansal | Sales | 60000 |
| Harpreet Kaur | Support | 36000 |
| Farah Sheikh | Warehouse | 42000 |
4 rows
Examples on this page were run with SQLite 3.50.4 on 12 November 2026. The results shown are the real results.
This is an independent learning site. It is not connected with the makers of any database product. MySQL, PostgreSQL, SQL Server, Oracle and SQLite are named only so that you know where a piece of SQL works. The shop, its customers and its staff in the practice database are made up.