SQL Window Functions — Ranking & Analytic
The highest-signal SQL topic in interviews: ranking rows, comparing to previous rows, and computing running totals — without collapsing your data.
Learning Objectives
- Explain what a window function is and how it differs from
GROUP BY. - Use
ROW_NUMBER(),RANK(), andDENSE_RANK()— and explain how they handle ties. - Use
PARTITION BYto solve the "top-N per group" pattern. - Compare rows to their neighbors with
LAG()andLEAD(). - Compute running totals and percent-of-total with
SUM() OVER. - Avoid the "filter a window function in WHERE" trap.
0. The Sample Tables
We use customers (now 7 rows, with a deliberate tie) and a transactions table with dates.
| customer_id | customer_name | city | balance |
|---|---|---|---|
| 1 | Aarav Sharma | Mumbai | 25000 |
| 2 | Priya Verma | Delhi | 120000 |
| 3 | Rahul Mehta | Mumbai | 8000 |
| 4 | Sneha Iyer | Bengaluru | 95000 |
| 5 | Vikram Singh | Delhi | 45000 |
| 6 | Ananya Das | Chennai | 15000 |
| 7 | Karan Patel | Mumbai | 95000 |
| txn_id | customer_id | amount | txn_date |
|---|---|---|---|
| 101 | 1 | 500 | 2024-01-10 |
| 102 | 1 | 1200 | 2024-01-15 |
| 103 | 2 | 800 | 2024-01-20 |
| 104 | 3 | 300 | 2024-02-05 |
| 105 | 1 | 950 | 2024-02-10 |
| 106 | 4 | 1500 | 2024-02-15 |
| 107 | 2 | 600 | 2024-03-01 |
| 108 | 1 | 700 | 2024-03-05 |
RANK and DENSE_RANK differ.
1. What Is a Window Function?
A window function performs a calculation across a set of rows (the "window") while
keeping every row in the output. Unlike GROUP BY, it doesn't collapse rows —
you get the original detail and the aggregate side by side.
-- Show each customer's balance AND the overall maximum
SELECT customer_name,
balance,
MAX(balance) OVER () AS overall_max
FROM customers;
The OVER () clause is what marks a function as a window function. An empty OVER () means "the whole table is the window."
| GROUP BY | Window function | |
|---|---|---|
| Row detail | collapsed | preserved |
| Output rows | one per group | one per input row |
| Typical use | summary only | detail + summary together |
2. ROW_NUMBER, RANK, and DENSE_RANK
The three ranking functions differ only in how they handle ties.
SELECT customer_name,
balance,
ROW_NUMBER() OVER (ORDER BY balance DESC) AS row_num,
RANK() OVER (ORDER BY balance DESC) AS rank_,
DENSE_RANK() OVER (ORDER BY balance DESC) AS dense
FROM customers
ORDER BY balance DESC;
| customer_name | balance | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| Priya Verma | 120000 | 1 | 1 | 1 |
| Sneha Iyer | 95000 | 2 | 2 | 2 |
| Karan Patel | 95000 | 3 | 2 | 2 |
| Vikram Singh | 45000 | 4 | 4 | 3 |
| Aarav Sharma | 25000 | 5 | 5 | 4 |
| Ananya Das | 15000 | 6 | 6 | 5 |
| Rahul Mehta | 8000 | 7 | 7 | 6 |
3. PARTITION BY — Rank Within Groups
PARTITION BY divides rows into groups and applies the window function within each group.
This powers the canonical "top-N per group" pattern.
-- Rank customers by balance WITHIN each city
SELECT city, customer_name, balance,
RANK() OVER (PARTITION BY city ORDER BY balance DESC) AS city_rank
FROM customers;
The "top-N per group" pattern (memorize this)
-- Top 1 customer (by balance) in each city
SELECT city, customer_name, balance
FROM (
SELECT city, customer_name, balance,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY balance DESC) AS rn
FROM customers
) ranked
WHERE rn = 1;
4. LAG and LEAD — Compare to Neighbors
LAG returns the value from a previous row; LEAD returns the value from the next row.
-- Month-over-month change in transaction volume
WITH monthly AS (
SELECT DATE_TRUNC('month', txn_date) AS month,
SUM(amount) AS revenue
FROM transactions
GROUP BY DATE_TRUNC('month', txn_date)
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly
ORDER BY month;
The first row has no previous month, so its LAG is NULL — which is correct.
LAG/LEAD require an ORDER BY inside OVER —
otherwise "previous" and "next" have no meaning.
5. Running Totals and Percent of Total
Running total (cumulative sum)
SELECT txn_date,
amount,
SUM(amount) OVER (
ORDER BY txn_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM transactions
ORDER BY txn_date;
The frame clause ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is what makes it a
running total instead of a grand total.
Percent of total (share of a group)
-- Each customer's balance as a share of their city's total
SELECT customer_name, city, balance,
ROUND(100.0 * balance / SUM(balance) OVER (PARTITION BY city), 1) AS pct_of_city
FROM customers;
6. The "Filter a Window Function" Trap
You cannot reference a window function directly in WHERE, because WHERE
runs before window functions are computed.
-- ❌ WRONG: WHERE can't see rn
SELECT customer_name, ROW_NUMBER() OVER (ORDER BY balance DESC) AS rn
FROM customers
WHERE rn = 1;
-- ✅ CORRECT: wrap in a subquery/CTE and filter outside
SELECT * FROM (
SELECT customer_name, ROW_NUMBER() OVER (ORDER BY balance DESC) AS rn
FROM customers
) ranked
WHERE rn = 1;
7. Interview Questions (with Model Answers)
Window functions are the highest-signal SQL topic. Self-test before revealing.
IQ1. What is a window function, and how is it different from GROUP BY?
Model answer: "A window function calculates across a set of rows but keeps every row in the output, so I can see the detail and an aggregate side by side. GROUP BY collapses rows to one per group. I use a window function when I need both the row detail and the summary."
IQ2. What's the difference between ROW_NUMBER, RANK, and DENSE_RANK?
Model answer: "ROW_NUMBER is always unique. RANK gives tied rows the same rank and skips the next number. DENSE_RANK also gives ties the same rank but doesn't skip. For example, with ties on the top two, ROW_NUMBER gives 1,2,3; RANK gives 1,1,3; DENSE_RANK gives 1,1,2."
IQ3. What does PARTITION BY do, and how is it different from GROUP BY?
Model answer: "PARTITION BY divides rows into groups and applies the window function within each group — like ranking within each city. GROUP BY collapses rows into a summary; PARTITION BY keeps all rows while computing the aggregate per partition."
IQ4. How do you find the top 3 products per category?
Model answer: "I'd rank within each category using
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC), wrap it in a subquery, then filter
WHERE rn <= 3. This is the top-N-per-group pattern."
IQ5. How do you compute a running total?
Model answer: "I use SUM(amount) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW). The frame clause makes it cumulative — without it, you'd get a grand total on every row."
IQ6. How do you compute month-over-month change?
Model answer: "I'd aggregate by month, then use LAG(revenue) OVER (ORDER BY month)
to get the previous month's value, and subtract. The first row will have a NULL previous value, which is expected."
IQ7. How do you compute each row's percent of the total?
Model answer: "I divide the row's value by the total from
SUM(value) OVER (PARTITION BY category). The window function puts the category total on every row,
so no self-join is needed."
IQ8. Why can't you filter a window function in WHERE?
Model answer: "Because WHERE runs before window functions are computed. To filter on a window function result, I wrap it in a subquery or CTE and filter the outer query — that's the whole reason the top-N-per-group pattern uses an inner subquery."
Hands-On Project: Window Functions
Using customers and transactions, write a query for each task.
Steps
- Rank all customers by balance (highest first), showing ROW_NUMBER, RANK, and DENSE_RANK.
- Find the top customer by balance in each city.
- Show each customer's balance and its percentage of their city's total.
- Compute a running total of transaction amounts ordered by date.
- Show each transaction amount along with the previous transaction's amount (LAG).
View Solution / Walkthrough
-- 1. Rank by balance
SELECT customer_name, balance,
ROW_NUMBER() OVER (ORDER BY balance DESC) AS rn,
RANK() OVER (ORDER BY balance DESC) AS rk,
DENSE_RANK() OVER (ORDER BY balance DESC) AS dr
FROM customers;
-- 2. Top customer per city
SELECT city, customer_name, balance
FROM (
SELECT city, customer_name, balance,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY balance DESC) AS rn
FROM customers
) t WHERE rn = 1;
-- 3. Percent of city total
SELECT customer_name, city, balance,
ROUND(100.0 * balance / SUM(balance) OVER (PARTITION BY city), 1) AS pct
FROM customers;
-- 4. Running total by date
SELECT txn_date, amount,
SUM(amount) OVER (ORDER BY txn_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM transactions ORDER BY txn_date;
-- 5. Previous transaction amount
SELECT txn_id, amount,
LAG(amount) OVER (ORDER BY txn_date) AS prev_amount
FROM transactions ORDER BY txn_date;
Key Takeaways
Window functions keep every row — detail and aggregate side by side, unlike GROUP BY.
ROW_NUMBER is unique; RANK skips after ties; DENSE_RANK doesn't skip.
PARTITION BY + ROW_NUMBER is the "top-N per group" pattern — memorize it.
LAG/LEAD compare neighbors; SUM OVER with a frame gives running totals.
Can't filter window functions in WHERE — wrap in a subquery/CTE and filter outside.
Objective Questions — Test Your Understanding
Q1. Which window function always assigns a unique, consecutive number (no ties)?
Q2. When two rows tie, what does RANK() do?
Q3. Which clause divides rows into groups for a window function to run within each group?
Q4. Which function returns the value from the previous row?
Q5. Why can't you use a window function directly in the WHERE clause?