Session 07 · Phase 1: Foundations & SQL

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.

⏱ ~2 hrs 📚 Core content 🎯 Highest priority

Learning Objectives

0. The Sample Tables

We use customers (now 7 rows, with a deliberate tie) and a transactions table with dates.

customer_idcustomer_namecitybalance
1Aarav SharmaMumbai25000
2Priya VermaDelhi120000
3Rahul MehtaMumbai8000
4Sneha IyerBengaluru95000
5Vikram SinghDelhi45000
6Ananya DasChennai15000
7Karan PatelMumbai95000
txn_idcustomer_idamounttxn_date
10115002024-01-10
102112002024-01-15
10328002024-01-20
10433002024-02-05
10519502024-02-10
106415002024-02-15
10726002024-03-01
10817002024-03-05
📝
Note: Sneha Iyer and Karan Patel both have balance 95000 — this tie is on purpose, so we can see how 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 BYWindow function
Row detailcollapsedpreserved
Output rowsone per groupone per input row
Typical usesummary onlydetail + 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_namebalanceROW_NUMBERRANKDENSE_RANK
Priya Verma120000111
Sneha Iyer95000222
Karan Patel95000322
Vikram Singh45000443
Aarav Sharma25000554
Ananya Das15000665
Rahul Mehta8000776
⚠️
Read the tie behavior carefully: ROW_NUMBER is always unique (1,2,3…). RANK gives tied rows the same rank and skips the next (1,2,2,4…). DENSE_RANK gives tied rows the same rank but does not skip (1,2,2,3…). Use DENSE_RANK for "nth highest distinct value" and ROW_NUMBER for "exactly N rows."

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;
🌐
Real World: "Top 3 products per category", "highest-spending customer per region", "most recent order per customer" — all of these are the top-N-per-group pattern. Practice it until it's muscle memory.

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.

📝
Note: 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;
💡
Pro Tip: Always specify the frame explicitly in interviews. Default frames vary by database, and naming the frame shows you understand what's actually happening.

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;
⚠️
Rule of thumb: window functions are evaluated last (after WHERE, GROUP BY, HAVING). To filter on their result, wrap them in a subquery or CTE and filter the outer query.
📋 Stable content — Reviewed: August 2026

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

💡
Remember the mapping: ROW_NUMBER → dedup/exactly-N rows · RANK → ranking with gaps · DENSE_RANK → top-N distinct values · LAG → previous row · SUM OVER → running total / percent of total.

Hands-On Project: Window Functions

Using customers and transactions, write a query for each task.

Steps

  1. Rank all customers by balance (highest first), showing ROW_NUMBER, RANK, and DENSE_RANK.
  2. Find the top customer by balance in each city.
  3. Show each customer's balance and its percentage of their city's total.
  4. Compute a running total of transaction amounts ordered by date.
  5. 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

1

Window functions keep every row — detail and aggregate side by side, unlike GROUP BY.

2

ROW_NUMBER is unique; RANK skips after ties; DENSE_RANK doesn't skip.

3

PARTITION BY + ROW_NUMBER is the "top-N per group" pattern — memorize it.

4

LAG/LEAD compare neighbors; SUM OVER with a frame gives running totals.

5

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?