SQL ROW_NUMBER vs RANK vs DENSE_RANK
All three functions number rows in a chosen order, and they only differ when values tie. That difference decides whether "top 3 salaries" returns three rows, four rows or skips a rank, so it comes up in almost every window-function interview.
Examples run in MySQL 8 and PostgreSQL · Updated
In short
ROW_NUMBER: 1, 2, 3, 4, always unique.RANK: ties share a rank, then it skips (1, 1, 3).DENSE_RANK: ties share a rank, no gaps (1, 1, 2).PARTITION BYrestarts the numbering for each group.
How window functions work
A window function computes a value for each row from a "window" of related rows, without collapsing them like GROUP BY does. OVER (PARTITION BY dept_id ORDER BY salary DESC) means: within each department, ordered by salary from highest.
In Sales, Meera and Arjun both earn 64,000. RANK and DENSE_RANK give them both 1. ROW_NUMBER must still give 1 and 2, so add a tie-breaker (here name) to make that choice predictable.
Which one to use
Use ROW_NUMBER for exactly one row per group (deduplication, latest record). Use DENSE_RANK for "top N distinct values" such as the top three salary levels. Use RANK when you need competition-style ranking, where ties push later ranks down.
The example tables
Every example on this page uses these tables. They are small on purpose, so you can check each result by eye.
employees
| emp_id | name | dept_id | salary | manager_id | hire_date |
|---|---|---|---|---|---|
| 1 | Aarav | 10 | 95000 | NULL | 2021-03-01 |
| 2 | Diya | 10 | 88000 | 1 | 2022-06-15 |
| 3 | Kabir | 10 | 72000 | 1 | 2023-01-10 |
| 4 | Meera | 20 | 64000 | NULL | 2020-11-20 |
| 5 | Rohan | 20 | 58000 | 4 | 2024-02-05 |
| 6 | Isha | 30 | 61000 | NULL | 2022-09-12 |
| 7 | Arjun | 20 | 64000 | 4 | 2023-07-30 |
| 8 | Zoya | NULL | 52000 | NULL | 2024-05-01 |
Examples
All three side by side
Sales only, so the tie is easy to see.
SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, name) AS row_num,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dense_rnk
FROM employees
WHERE dept_id = 20
ORDER BY salary DESC, name;| name | dept_id | salary | row_num | rnk | dense_rnk |
|---|---|---|---|---|---|
| Arjun | 20 | 64000 | 1 | 1 | 1 |
| Meera | 20 | 64000 | 2 | 1 | 1 |
| Rohan | 20 | 58000 | 3 | 3 | 2 |
Gaps vs no gaps
Across the whole company, after the 64,000 tie RANK jumps to 6 while DENSE_RANK continues at 5.
SELECT name, salary,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk
FROM employees
ORDER BY salary DESC, name;| name | salary | rnk | dense_rnk |
|---|---|---|---|
| Aarav | 95000 | 1 | 1 |
| Diya | 88000 | 2 | 2 |
| Kabir | 72000 | 3 | 3 |
| Arjun | 64000 | 4 | 4 |
| Meera | 64000 | 4 | 4 |
| Isha | 61000 | 6 | 5 |
| Rohan | 58000 | 7 | 6 |
| Zoya | 52000 | 8 | 7 |
Top earner per department
Number rows inside each department, then keep row 1. The filter has to happen outside, in a CTE.
WITH ranked AS (
SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, name) AS rn
FROM employees
WHERE dept_id IS NOT NULL
)
SELECT dept_id, name, salary
FROM ranked
WHERE rn = 1
ORDER BY dept_id;| dept_id | name | salary |
|---|---|---|
| 10 | Aarav | 95000 |
| 20 | Arjun | 64000 |
| 30 | Isha | 61000 |
Common mistakes
- ROW_NUMBER when ties should count. "Highest-paid employees per department" usually means everyone tied at the top. Use
RANKorDENSE_RANKthen. - No tie-breaker. With ties,
ROW_NUMBERpicks an arbitrary order unless you add a secondORDER BYcolumn. - Filtering in the same SELECT. You cannot write
WHERE rn = 1next to the window function. Wrap it in a CTE or subquery.
Practise ROW_NUMBER vs RANK vs DENSE_RANK
Interview questions that use this concept, easiest first. Each one has the step-by-step visualizer and answer checking.
Rank Salaries Within Department
Assign dense rank values to employee compensations per department without skipping ranks for tied salaries.
Month-over-Month Revenue Growth
Compute monthly successful payment volume and its percentage change versus the prior month.
Running Total of Daily Sales
Calculate a cumulative running balance of transaction amounts partitioned by retail store location.
Amazon: First Order Details
Retrieve each customer's very first order, resolving date ties by selecting the lowest order_id.
Amazon: Days Between Repeat Orders
Calculate the average gap in days between consecutive purchases for customers with repeat orders.
Amazon: Best-Selling Products per Category
Identify the top-selling product in each retail category by total units sold, returning all tied leaders using DENSE_RANK.
Want a full set? Work through the Window Functions study plan.
Questions people ask
- Does MySQL support window functions?
- Yes, since MySQL 8.0. PostgreSQL has supported them for much longer. The syntax shown here works in both.
- What does PARTITION BY do?
- It splits rows into groups and restarts the numbering in each group, while keeping every row in the result.