Window Functions
Aggregates with GROUP BY collapse rows into one per group. Window functions calculate across a set of related rows without collapsing them — each row keeps its identity and gains a value computed from its "window": a department average beside each salary, a running total, the previous order's date.
The OVER clause
SELECT first_name, department_id, salary, round(AVG(salary) OVER ()) AS avg_of_result FROM employees WHERE department_id IN (1, 2);
| first_name | department_id | salary | avg_of_result |
|---|---|---|---|
| Rafael | 1 | 82000 | 72429 |
| Mei | 2 | 98000 | 72429 |
| Jonas | 2 | 85000 | 72429 |
| Amara | 2 | 68000 | 72429 |
| Lucas | 1 | 52000 | 72429 |
| Sofia | 1 | 54000 | 72429 |
| Tomás | 2 | 68000 | 72429 |
Adding OVER (…) turns an aggregate into a window function. With an empty OVER (), the window is the whole result, so every row shows the same average — but all the rows are still there. Because window functions run after WHERE, it's the average of the Sales and Engineering rows shown, not of the whole company.
PARTITION BY: windows per group
SELECT first_name, department_id, salary, round(AVG(salary) OVER (PARTITION BY department_id)) AS dept_avg, round(salary - AVG(salary) OVER (PARTITION BY department_id)) AS vs_dept FROM employees WHERE department_id IN (1, 2) ORDER BY department_id, salary DESC;
| first_name | department_id | salary | dept_avg | vs_dept |
|---|---|---|---|---|
| Rafael | 1 | 82000 | 62667 | 19333 |
| Sofia | 1 | 54000 | 62667 | -8667 |
| Lucas | 1 | 52000 | 62667 | -10667 |
| Mei | 2 | 98000 | 79750 | 18250 |
| Jonas | 2 | 85000 | 79750 | 5250 |
| Amara | 2 | 68000 | 79750 | -11750 |
| Tomás | 2 | 68000 | 79750 | -11750 |
PARTITION BY splits rows into groups, like GROUP BY, but the function's result is attached to every row of the group. Comparing each value with its group's average is now a single, readable query.
Share of a total
SELECT category, name, round(price * stock, 2) AS stock_value, round(100.0 * price * stock / SUM(price * stock) OVER (PARTITION BY category), 1) AS pct_of_category FROM products WHERE category IN ('Home', 'Toys') ORDER BY category, pct_of_category DESC;
| category | name | stock_value | pct_of_category |
|---|---|---|---|
| Home | Ceramic Mug | 2500 | 55.8 |
| Home | Linen Throw | 1470 | 32.8 |
| Home | Desk Lamp | 510 | 11.4 |
| Toys | Jigsaw Puzzle 1000 | 900 | 65.3 |
| Toys | Wooden Train Set | 478.8 | 34.7 |
ORDER BY inside OVER: running totals
SELECT id, order_date, COUNT(*) OVER (ORDER BY order_date) AS orders_so_far FROM orders WHERE order_date < '2025-12-31';
| id | order_date | orders_so_far |
|---|---|---|
| 1 | 2025-09-03 | 1 |
| 2 | 2025-09-14 | 2 |
| 3 | 2025-10-01 | 3 |
| 4 | 2025-10-20 | 4 |
| 5 | 2025-11-02 | 5 |
| 6 | 2025-11-15 | 6 |
| 7 | 2025-12-01 | 7 |
| 8 | 2025-12-12 | 8 |
Adding ORDER BY inside OVER makes the window grow row by row: each row sees itself and every row before it. That turns COUNT and SUM into running totals.
Where window functions can go
Window functions are calculated after WHERE, GROUP BY and HAVING, so they can appear only in the select list and ORDER BY. To filter on one, wrap the query in a CTE or subquery:
WITH ranked AS ( SELECT first_name, department_id, salary, AVG(salary) OVER (PARTITION BY department_id) AS dept_avg FROM employees ) SELECT first_name, department_id, salary, round(dept_avg) AS dept_avg FROM ranked WHERE salary > dept_avg;
| first_name | department_id | salary | dept_avg |
|---|---|---|---|
| Rafael | 1 | 82000 | 62667 |
| Mei | 2 | 98000 | 79750 |
| Jonas | 2 | 85000 | 79750 |
| Daniel | 3 | 71000 | 59500 |
| Ethan | 4 | 60000 | 51000 |
Windows over grouped results
SELECT strftime('%Y-%m', o.order_date) AS month, round(SUM(oi.quantity * oi.unit_price), 2) AS revenue, round(SUM(SUM(oi.quantity * oi.unit_price)) OVER (ORDER BY strftime('%Y-%m', o.order_date)), 2) AS cumulative FROM orders AS o JOIN order_items AS oi ON oi.order_id = o.id WHERE o.status <> 'cancelled' GROUP BY month ORDER BY month;
| month | revenue | cumulative |
|---|---|---|
| 2025-09 | 109.89 | 109.89 |
| 2025-10 | 171.48 | 281.37 |
| 2025-11 | 81 | 362.37 |
| 2025-12 | 148.89 | 511.26 |
| 2026-01 | 203.78 | 715.04 |
| 2026-02 | 162 | 877.04 |
| 2026-03 | 177.98 | 1055.02 |
The SUM(SUM(…)) OVER (…) looks strange but is logical: the inner SUM is the monthly aggregate, and the outer window SUM accumulates those monthly totals.
Named windows
SELECT first_name, salary, MIN(salary) OVER w AS dept_min, MAX(salary) OVER w AS dept_max FROM employees WHERE department_id = 1 WINDOW w AS (PARTITION BY department_id);
| first_name | salary | dept_min | dept_max |
|---|---|---|---|
| Rafael | 82000 | 52000 | 82000 |
| Lucas | 52000 | 52000 | 82000 |
| Sofia | 54000 | 52000 | 82000 |
The WINDOW clause defines a window once for several functions. It's supported by PostgreSQL, MySQL 8, SQLite and SQL Server 2022.
Try it yourself
Show each product with its category's cheapest and most expensive price.