RANK() and DENSE_RANK() — Handling Ties in Rankings
title: “RANK() and DENSE_RANK()” part: 3 topic_number: 5 slug: “rank” difficulty: “Advanced” prerequisites: “row-number” —
RANK() and DENSE_RANK() — Handling Ties in Rankings
What Is It?
RANK() and DENSE_RANK() are window functions (like ROW_NUMBER()) that handle ties differently:
- ROW_NUMBER(): Always unique (1, 2, 3, 4…)
- RANK(): Same rank for ties, skips numbers (1, 2, 2, 4, 5…)
- DENSE_RANK(): Same rank for ties, no gaps (1, 2, 2, 3, 4…)
Real-world analogy: Olympic medals — two athletes tie for silver (both get rank 2), but the next athlete gets bronze (rank 3 with DENSE_RANK, rank 4 with RANK).
See the Difference Immediately
Here is what all three produce when there are ties — memorise this table before reading further:
| Score | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 90 | 2 | 2 | 2 |
| 90 | 3 | 2 | 2 |
| 80 | 4 | 4 ← gap | 3 ← no gap |
| 70 | 5 | 5 | 4 |
- ROW_NUMBER — never repeats, always unique, tie-breaking is arbitrary
- RANK — ties share a number, then the count of rows above determines the next rank (two people tied at 2 → next rank is 4, not 3)
- DENSE_RANK — ties share a number, next rank always increments by 1
Quick rule: use DENSE_RANK for “top N salary levels”; use RANK for “true position”; use ROW_NUMBER when you need a unique number per row.
Syntax (Same as ROW_NUMBER)
1
2
3
4
5
6
7
8
9
RANK() OVER (
[PARTITION BY column(s)]
ORDER BY column(s) [ASC|DESC]
)
DENSE_RANK() OVER (
[PARTITION BY column(s)]
ORDER BY column(s) [ASC|DESC]
)
Side-by-Side Comparison (With Ties)
Use a WITH clause to create a small dataset with a deliberate tie — no need to UPDATE real data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
WITH scores AS (
SELECT 'Alice' AS name, 95000 AS salary UNION ALL
SELECT 'Frank', 93000 UNION ALL
SELECT 'Henry', 92000 UNION ALL
SELECT 'Bob', 82000 UNION ALL
SELECT 'Carol', 82000 UNION ALL -- TIE with Bob
SELECT 'Eve', 78000
)
SELECT
name,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk
FROM scores
ORDER BY salary DESC, name;
Expected output:
| name | salary | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Alice | 95000 | 1 | 1 | 1 |
| Frank | 93000 | 2 | 2 | 2 |
| Henry | 92000 | 3 | 3 | 3 |
| Bob | 82000 | 4 | 4 | 4 |
| Carol | 82000 | 5 | 4 | 4 |
| Eve | 78000 | 6 | 6 ← gap | 5 ← no gap |
Key differences:
- ROW_NUMBER: Bob=4, Carol=5 (always unique, tie broken by name)
- RANK: Both get 4, Eve gets 6 (skips 5 — two people occupied positions 4 and 5)
- DENSE_RANK: Both get 4, Eve gets 5 (no gap, just “next distinct level”)
Going Deeper
Top 3 Salaries (Including Ties)
1
2
3
4
5
6
7
8
9
10
11
12
WITH ranked AS (
SELECT
first_name,
last_name,
salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees
)
SELECT *
FROM ranked
WHERE dense_rank <= 3
ORDER BY dense_rank, salary DESC, last_name;
Expected output:
| first_name | last_name | salary | dense_rank |
|---|---|---|---|
| Sam | Clark | 95000.00 | 1 |
| Frank | Miller | 93000.00 | 2 |
| Henry | Moore | 92000.00 | 3 |
Top 3 unique salary levels (if there were ties, would include all tied employees).
Department Rankings
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SELECT
e.first_name,
e.last_name,
e.salary,
d.department_name,
RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS dept_rank,
CASE
WHEN RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) = 1 THEN 'Gold'
WHEN RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) = 2 THEN 'Silver'
WHEN RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) = 3 THEN 'Bronze'
ELSE ''
END AS medal
FROM employees e
JOIN departments d ON e.department_id = d.department_id
ORDER BY d.department_name, dept_rank;
Expected output:
| first_name | last_name | salary | department_name | dept_rank | medal |
|---|---|---|---|---|---|
| Sam | Clark | 95000.00 | Engineering | 1 | Gold |
| Paul | Garcia | 87000.00 | Engineering | 2 | Silver |
| Alice | Johnson | 75000.00 | Engineering | 3 | Bronze |
| Henry | Moore | 92000.00 | Finance | 1 | Gold |
| David | Brown | 66000.00 | Finance | 2 | Silver |
| … | … | … | … | … | … |
Top performers per department with medals!
Percentile Rankings
1
2
3
4
5
6
7
SELECT
first_name,
salary,
RANK() OVER (ORDER BY salary DESC) AS salary_rank,
ROUND(PERCENT_RANK() OVER (ORDER BY salary DESC) * 100, 1) AS percentile
FROM employees
ORDER BY salary DESC;
Expected output:
| first_name | salary | salary_rank | percentile |
|---|---|---|---|
| Sam | 95000.00 | 1 | 0.0 |
| Frank | 93000.00 | 2 | 5.3 |
| Henry | 92000.00 | 3 | 10.5 |
| Paul | 87000.00 | 4 | 15.8 |
| Bob | 82000.00 | 5 | 21.1 |
| … | … | … | … |
Percentile rank (Sam is in top 0%, Frank in top 5.3%, etc.).
Finding Ties
1
2
3
4
5
6
7
8
SELECT
salary,
COUNT(*) AS employee_count,
GROUP_CONCAT(first_name ORDER BY first_name) AS employees
FROM employees
GROUP BY salary
HAVING COUNT(*) > 1
ORDER BY salary DESC;
Expected output:
| salary | employee_count | employees |
|---|---|---|
| 82000.00 | 2 | Bob,Carol |
| 70000.00 | 2 | Jack,Noah |
Salaries with ties (multiple employees).
Pause and Predict: When would you use RANK() vs DENSE_RANK()?
Answer
**Use RANK() when:** - You want rankings to reflect the "true" position (e.g., "4th place" after two people tied for 2nd) - Gap numbers matter conceptually (Olympic-style ranking) **Use DENSE_RANK() when:** - You need continuous rankings without gaps - Counting "distinct levels" (top 5 salary levels, including all ties) - Creating categories/buckets **Use ROW_NUMBER() when:** - You need absolutely unique identifiers - Breaking ties arbitrarily is acceptable - Pagination or strict ordering requiredWatch Out — Common Mistakes
Mistake #1: Expecting ROW_NUMBER Behavior with RANK
1
2
3
4
5
6
7
-- WRONG ASSUMPTION
WITH ranked AS (
SELECT salary, RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
)
SELECT * FROM ranked WHERE rnk = 5;
-- Might return 0 rows if there are ties above rank 5!
Example: If 3 people tie for rank 2, next person is rank 5 (skipped 3 and 4). Your query looking for rank 5 might not find what you expect.
Mistake #2: Using DENSE_RANK for Pagination
1
2
3
4
5
6
-- BAD FOR PAGINATION
SELECT
*,
DENSE_RANK() OVER (ORDER BY employee_id) AS page
FROM employees
WHERE page BETWEEN 10 AND 20; -- Won't work as expected with gaps!
Fix: Use ROW_NUMBER for pagination (guarantees sequential numbers).
Mistake #3: Forgetting ORDER BY Matters
1
2
3
-- Different ORDER BY = different rankings!
RANK() OVER (ORDER BY salary DESC) -- Rank by highest salary
RANK() OVER (ORDER BY hire_date) -- Rank by seniority
Always consider what you’re ranking BY.
Edge Case Spotlight
All Ties (Everyone Same Value)
1
2
3
4
5
6
7
8
9
-- If everyone has same salary
UPDATE employees SET salary = 75000;
SELECT
first_name,
salary,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense
FROM employees;
Result: Everyone gets rank 1 for both RANK() and DENSE_RANK().
Combining with Aggregates
1
2
3
4
5
6
SELECT
department_id,
AVG(salary) AS avg_salary,
RANK() OVER (ORDER BY AVG(salary) DESC) AS dept_rank
FROM employees
GROUP BY department_id;
Expected output:
| department_id | avg_salary | dept_rank |
|---|---|---|
| 2 | 87500.00 | 1 |
| 1 | 85666.67 | 2 |
| 4 | 79000.00 | 3 |
| … | … | … |
Rank departments by average salary.
Try This
Exercise 1 (Guided)
Find all employees tied for the highest salary in their department. Show first_name, last_name, department_name, salary, and dept_rank.
Hint
Use RANK() OVER (PARTITION BY department_id ORDER BY salary DESC), filter WHERE dept_rank = 1.Exercise 2 (Independent)
Create a “performance quintile” report: divide employees into 5 groups based on salary (top 20%, next 20%, etc.). Show first_name, salary, and quintile (1-5).
Hint
Use NTILE(5) OVER (ORDER BY salary DESC) — NTILE divides into N roughly equal buckets.Exercise 3 (Challenge)
Find “salary outliers”: employees whose salary rank within their department is very different from their overall company rank (difference > 3). Show name, salary, dept_rank, overall_rank, and rank_difference.
Hint
Use two RANK() windows (one with PARTITION BY dept, one without), calculate ABS(dept_rank - overall_rank), filter > 3.Answer Key
Exercise 1 Answer
```sql WITH ranked_employees AS ( SELECT e.first_name, e.last_name, e.salary, d.department_name, RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS dept_rank FROM employees e JOIN departments d ON e.department_id = d.department_id ) SELECT * FROM ranked_employees WHERE dept_rank = 1 ORDER BY department_name; ``` **Expected output:** | first_name | last_name | salary | department_name | dept_rank | |------------|-----------|---------|-----------------|-----------| | Sam | Clark | 95000.00 | Engineering | 1 | | Henry | Moore | 92000.00 | Finance | 1 | | Ivy | Anderson | 74000.00 | Human Resources | 1 | | George | Jones | 71000.00 | Legal | 1 | | ... | ... | ... | ... | ... | Top earners per department (includes all ties).Exercise 2 Answer
```sql SELECT first_name, last_name, salary, NTILE(5) OVER (ORDER BY salary DESC) AS quintile, CASE NTILE(5) OVER (ORDER BY salary DESC) WHEN 1 THEN 'Top 20%' WHEN 2 THEN '60-80%' WHEN 3 THEN '40-60%' WHEN 4 THEN '20-40%' WHEN 5 THEN 'Bottom 20%' END AS performance_group FROM employees ORDER BY salary DESC; ``` **Expected output:** | first_name | last_name | salary | quintile | performance_group | |------------|-----------|---------|----------|-------------------| | Sam | Clark | 95000.00 | 1 | Top 20% | | Frank | Miller | 93000.00 | 1 | Top 20% | | Henry | Moore | 92000.00 | 1 | Top 20% | | Paul | Garcia | 87000.00 | 1 | Top 20% | | Bob | Smith | 82000.00 | 2 | 60-80% | | ... | ... | ... | ... | ... | Employees divided into performance quintiles. **Note:** NTILE(5) creates 5 buckets of roughly equal size.Exercise 3 Answer
```sql WITH rankings AS ( SELECT e.first_name, e.last_name, e.salary, d.department_name, RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS dept_rank, RANK() OVER (ORDER BY e.salary DESC) AS overall_rank FROM employees e JOIN departments d ON e.department_id = d.department_id ) SELECT first_name, last_name, salary, department_name, dept_rank, overall_rank, ABS(dept_rank - overall_rank) AS rank_difference FROM rankings WHERE ABS(dept_rank - overall_rank) > 3 ORDER BY rank_difference DESC; ``` **Expected output:** | first_name | last_name | salary | department_name | dept_rank | overall_rank | rank_difference | |------------|-----------|---------|-----------------|-----------|--------------|-----------------| | Alice | Johnson | 75000.00 | Engineering | 3 | 7 | 4 | | David | Brown | 66000.00 | Finance | 2 | 12 | 10 | Employees whose department rank differs significantly from overall rank. **Interpretation:** David is #2 in his department but only #12 overall → his department has lower salaries.Quick Recap
• RANK() assigns same rank to ties, skips numbers (1, 2, 2, 4…)
• DENSE_RANK() assigns same rank to ties, no gaps (1, 2, 2, 3…)
• ROW_NUMBER() always unique (1, 2, 3, 4…)
• Use RANK for “true position” rankings
• Use DENSE_RANK for “top N levels” including all ties
• Use ROW_NUMBER for pagination or unique IDs
• NTILE(n) divides into n equal buckets
• PERCENT_RANK() calculates percentile (0.0 to 1.0)
Up Next
Next topic: Transactions (BEGIN, COMMIT, ROLLBACK) → part3_06_transactions.md
Type ‘next’ when ready to continue!