Chapter 3: Aggregate Functions and Window Functions
These three functions assign a number to each row based on ordering. They look similar but behave differently when there are ties. This lesson will make the difference crystal clear with a single example table.
Let us rank students by their exam score. Notice what happens when two students have the same score (a tie).
| Student | Score | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| Priya | 95 | 1 | 1 | 1 |
| Amit | 90 | 2 | 2 | 2 |
| Sneha | 90 | 3 | 2 | 2 |
| Rahul | 85 | 4 | 4 | 3 |
| Neha | 80 | 5 | 5 | 4 |
See the differences? Amit and Sneha both scored 90. ROW_NUMBER gives them different numbers (2, 3) — it never repeats. RANK gives them both 2, then skips to 4 for Rahul — it leaves a gap. DENSE_RANK gives them both 2, then continues with 3 for Rahul — no gap.
-- Compare all three ranking functions side by side
SELECT
student_name,
score,
ROW_NUMBER() OVER(ORDER BY score DESC) AS row_num,
RANK() OVER(ORDER BY score DESC) AS rank_val,
DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rank_val
FROM students;
-- QA scenario: Rank orders by amount per customer
SELECT
customer_id,
order_id,
total_amount,
ROW_NUMBER() OVER(
PARTITION BY customer_id
ORDER BY total_amount DESC
) AS amount_rank
FROM orders;This is the single most useful window function pattern. You want the most recent order per customer. Or the latest login per user. Or the highest-scoring test per student. ROW_NUMBER with PARTITION BY and a subquery does it.
-- Most recent order per customer
SELECT * FROM (
SELECT
customer_id,
order_id,
order_date,
total_amount,
ROW_NUMBER() OVER(
PARTITION BY customer_id
ORDER BY order_date DESC
) AS rn
FROM orders
) ranked
WHERE rn = 1;
-- Latest login per user
SELECT * FROM (
SELECT
user_id,
login_at,
ip_address,
ROW_NUMBER() OVER(
PARTITION BY user_id
ORDER BY login_at DESC
) AS rn
FROM login_history
) ranked
WHERE rn = 1;When you need "top N per group," change WHERE rn = 1 to WHERE rn <= 3. That gives you the top 3 records per group. This pattern works for any N.
ROW_NUMBER does not guarantee a stable order for tied rows. If two orders have the same date, which one gets rn=1 is random and can change between query executions. Always add a tiebreaker column: ORDER BY order_date DESC, order_id DESC.
Q: What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
A: ROW_NUMBER assigns unique sequential numbers — no ties, no gaps. RANK allows ties but skips numbers after (1, 2, 2, 4). DENSE_RANK allows ties with no gaps (1, 2, 2, 3). Use ROW_NUMBER when you need exactly one row per group (like "latest order per customer"). Use RANK or DENSE_RANK when you need to handle ties explicitly, like leaderboards where tied players should share a rank.
Key Point: ROW_NUMBER = unique numbers. RANK = ties with gaps. DENSE_RANK = ties without gaps. The "latest per group" pattern with ROW_NUMBER + PARTITION BY is the most useful window function pattern in QA.