Window Functions¶
Window functions are the most powerful tool in SQL for analytics. They compute a value across a set of rows related to the current row — without collapsing the result like GROUP BY does. Every row stays visible, but each row now knows its rank, its running total, or how it compares to the previous period.
Learning Objectives¶
- Understand the difference between GROUP BY aggregation and window functions
- Use
OVER(),PARTITION BY, andORDER BYto define windows - Apply ranking functions:
ROW_NUMBER,RANK,DENSE_RANK - Compute running totals and moving averages with
SUM OVERandAVG OVER - Access adjacent rows with
LAGandLEAD
What Makes Window Functions Different¶
-- GROUP BY: collapses rows — you lose the individual order details
SELECT customer_id, SUM(order_total) AS total
FROM orders GROUP BY customer_id;
-- Returns 1 row per customer
-- Window function: keeps every row, adds aggregate as a column
SELECT order_id, customer_id, order_total,
SUM(order_total) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
-- Returns 1 row per order, with the customer's total added to each row
Sample output:
| order_id | customer_id | order_total | customer_total |
|---|---|---|---|
| O001 | C001 | 298.00 | 333.99 |
| O004 | C001 | 35.99 | 333.99 |
| O002 | C003 | 389.00 | 638.95 |
| O007 | C003 | 249.95 | 638.95 |
The OVER() Clause — Defining the Window¶
The OVER() clause is what makes a function a window function.
function_name() OVER (
PARTITION BY column -- divide rows into groups (optional)
ORDER BY column -- sort within each group (required for ranking/running totals)
ROWS BETWEEN ... -- define the frame (optional)
)
PARTITION BY— like GROUP BY for the window. Omit it to treat all rows as one group.ORDER BY— sort within each partition. Required for ranking and cumulative functions.ROWS BETWEEN— the "frame" — which rows relative to the current row to include. Defaults to all preceding rows when using ORDER BY.
Ranking Functions¶
ROW_NUMBER() — unique sequential number¶
SELECT
order_id,
customer_id,
order_date,
order_total,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_total DESC
) AS rank_within_customer
FROM orders
WHERE status = 'completed';
Sample output:
| order_id | customer_id | order_date | order_total | rank_within_customer |
|---|---|---|---|---|
| O001 | C001 | 2024-01-05 | 298.00 | 1 |
| O004 | C001 | 2024-01-10 | 35.99 | 2 |
| O002 | C003 | 2024-01-07 | 389.00 | 1 |
| O007 | C003 | 2024-01-15 | 249.95 | 2 |
RANK() vs DENSE_RANK() — handling ties¶
SELECT
product_id,
category,
unit_price,
RANK() OVER (PARTITION BY category ORDER BY unit_price DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY category ORDER BY unit_price DESC) AS dense_rank,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY unit_price DESC) AS row_num
FROM products;
| unit_price | rank | dense_rank | row_num |
|---|---|---|---|
| 389.00 | 1 | 1 | 1 |
| 149.00 | 2 | 2 | 2 |
| 89.99 | 3 | 3 | 3 |
| 49.99 | 4 | 4 | 4 |
| 49.99 | 4 | 4 | 5 |
| 35.99 | 6 | 5 | 6 |
RANK(): ties get the same rank, next rank skips (4, 4, 6)DENSE_RANK(): ties get the same rank, no skipping (4, 4, 5)ROW_NUMBER(): always unique, arbitrary tie-breaking
The N-th row per group pattern — top 1 order per customer:
WITH ranked_orders AS (
SELECT
order_id,
customer_id,
order_date,
order_total,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_total DESC
) AS rn
FROM orders
WHERE status = 'completed'
)
SELECT order_id, customer_id, order_date, order_total
FROM ranked_orders
WHERE rn = 1; -- largest order per customer
For senior analysts
This "ROW_NUMBER + WHERE rn = 1" pattern is one of the most common in analytics — it's how you get the latest, first, or Nth record per group without a complicated GROUP BY.
Running Totals and Moving Averages¶
Cumulative SUM¶
SELECT
order_date,
order_total,
SUM(order_total) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
WHERE status = 'completed'
ORDER BY order_date;
Sample output:
| order_date | order_total | running_total |
|---|---|---|
| 2024-01-05 | 298.00 | 298.00 |
| 2024-01-07 | 389.00 | 687.00 |
| 2024-01-10 | 35.99 | 722.99 |
| 2024-01-14 | 179.98 | 902.97 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
This is the default frame when you use ORDER BY in OVER(). You can write it explicitly or let it default. It means: "from the beginning of the partition to the current row."
Running total partitioned by customer¶
SELECT
customer_id,
order_date,
order_total,
SUM(order_total) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS cumulative_spend
FROM orders
WHERE status = 'completed'
ORDER BY customer_id, order_date;
3-month moving average (monthly data)¶
WITH monthly_revenue AS (
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(order_total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
)
SELECT
month,
revenue,
AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3m
FROM monthly_revenue
ORDER BY month;
LAG and LEAD — Accessing Adjacent Rows¶
LAG() accesses the previous row. LEAD() accesses the next row.
Business use case: month-over-month revenue change.
WITH monthly_revenue AS (
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(order_total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
)
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month) * 100, 1
) AS mom_pct_change
FROM monthly_revenue
ORDER BY month;
Sample output:
| month | revenue | prev_month_revenue | mom_change | mom_pct_change |
|---|---|---|---|---|
| 2024-01 | 4829.50 | NULL | NULL | NULL |
| 2024-02 | 6145.00 | 4829.50 | 1315.50 | 27.2 |
| 2024-03 | 5890.00 | 6145.00 | -255.00 | -4.1 |
For mid-level analysts
LAG(column, n, default) takes up to 3 arguments: the column, how many rows back (default 1), and a default value when there's no previous row (default NULL). LAG(revenue, 12) gives you the same month last year.
NTILE — Percentile Buckets¶
Divide rows into N equal groups:
-- Divide customers into quartiles by total spend
WITH customer_spend AS (
SELECT customer_id, SUM(order_total) AS total_spend
FROM orders WHERE status = 'completed'
GROUP BY customer_id
)
SELECT
customer_id,
total_spend,
NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_quartile
FROM customer_spend;
Quartile 1 = top 25% spenders. Used in RFM segmentation.
Practice Exercises¶
Warm-up
1. Add a ROW_NUMBER() to the orders table, ordered by order_date ascending.
2. Rank products by unit_price descending within each category using DENSE_RANK().
3. Calculate a running total of revenue ordered by order_date.
Main
4. For each customer, find their single highest-value completed order (using ROW_NUMBER + CTE).
5. Calculate month-over-month revenue change and percentage change for completed orders in 2024.
6. Add a column showing each customer's total spend alongside individual order details (use SUM OVER PARTITION BY).
Stretch
7. Calculate a 3-month moving average of monthly revenue. Identify which months were above the moving average.
8. Build an RFM-style query: for each customer, calculate total orders, total spend, and days since last order. Then use NTILE(5) to score each metric on a 1-5 scale.
Interview Questions¶
[Beginner] What is a window function and how is it different from GROUP BY?
[Mid-level] What does PARTITION BY do in a window function?
[Mid-level] What is the difference between RANK() and DENSE_RANK()? Give an example where the difference matters.
[Mid-level] Write a query that returns the most recent order for each customer.
[Senior] You need to calculate 7-day rolling average of daily revenue on a 500M-row table. The query runs in 12 minutes. What are your optimisation strategies?