SQL ROW_NUMBER(), RANK(), and DENSE_RANK() as Window Functions
When working with SQL, you will frequently need to number rows, rank results, or pick the top-N records per group. The three window functions designed exactly for these tasks are ROW_NUMBER(), RANK(), and DENSE_RANK(). They look similar at first glance, but each one handles tied values differently, and choosing the wrong one can silently produce incorrect results.
This guide explains how each function works, how they differ when ties occur, and walks you through the most common real-world use cases including top-N per group queries and row deduplication. Every concept includes practical examples with clear outputs so you can follow along immediately.
Prerequisites
Before diving in, you should be comfortable with the basics of SQL window functions and the OVER() clause. If terms like PARTITION BY or window frame feel unfamiliar, review an introductory guide to SQL window functions first.
All three functions covered here are dedicated window functions. They can only be used with the OVER() clause and have no standalone aggregate equivalent.
The Sample Data
Throughout this guide, we will use the following employees table:
| id | name | department | salary |
|---|---|---|---|
| 1 | Alice | Engineering | 95000 |
| 2 | Bob | Engineering | 90000 |
| 3 | Charlie | Engineering | 90000 |
| 4 | Diana | Sales | 70000 |
| 5 | Eve | Sales | 65000 |
| 6 | Frank | Sales | 65000 |
| 7 | Grace | Sales | 60000 |
| 8 | Hank | Marketing | 72000 |
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
department VARCHAR(50) NOT NULL,
salary DECIMAL(10,2) NOT NULL
);
INSERT INTO employees (id, name, department, salary) VALUES
(1, 'Alice', 'Engineering', 95000),
(2, 'Bob', 'Engineering', 90000),
(3, 'Charlie', 'Engineering', 90000),
(4, 'Diana', 'Sales', 70000),
(5, 'Eve', 'Sales', 65000),
(6, 'Frank', 'Sales', 65000),
(7, 'Grace', 'Sales', 60000),
(8, 'Hank', 'Marketing', 72000);
Notice that Bob and Charlie share the same salary (90000) and Eve and Frank share the same salary (65000). These ties are critical for understanding the differences between the three functions.
ROW_NUMBER()
ROW_NUMBER() assigns a unique, sequential integer to each row within its partition, based on the specified ORDER BY. No two rows in the same partition will ever receive the same number, even if they have identical values in the ordering column.
Syntax
ROW_NUMBER() OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC], ...
)
ORDER BY inside the OVER() clause is required for ROW_NUMBER(). Without it, the assignment of numbers is non-deterministic, meaning the database can return rows in any order and number them arbitrarily.
Basic Example: Numbering All Rows
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num
FROM employees;
Output:
| name | department | salary | row_num |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 3 |
| Hank | Marketing | 72000 | 4 |
| Diana | Sales | 70000 | 5 |
| Eve | Sales | 65000 | 6 |
| Frank | Sales | 65000 | 7 |
| Grace | Sales | 60000 | 8 |
Bob and Charlie both earn 90000, but ROW_NUMBER() still gives them different numbers (2 and 3). Which one gets 2 and which gets 3 is not guaranteed unless you add a tiebreaker column to the ORDER BY.
Adding a Tiebreaker
To make the numbering deterministic when values are tied, add a secondary sort column:
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC, name ASC) AS row_num
FROM employees;
Output:
| name | department | salary | row_num |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 3 |
| Hank | Marketing | 72000 | 4 |
| Diana | Sales | 70000 | 5 |
| Eve | Sales | 65000 | 6 |
| Frank | Sales | 65000 | 7 |
| Grace | Sales | 60000 | 8 |
Now Bob always comes before Charlie because name ASC breaks the tie alphabetically.
When using ROW_NUMBER() without a tiebreaker on columns that contain duplicates, the results may vary between query executions. Always add a unique or nearly unique column (like a primary key or name) as a secondary sort to ensure consistent, reproducible results.
Numbering Rows Within Partitions
The real power of ROW_NUMBER() emerges when combined with PARTITION BY. This restarts the numbering at 1 for each group:
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, name ASC
) AS dept_row_num
FROM employees;
Output:
| name | department | salary | dept_row_num |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 3 |
| Hank | Marketing | 72000 | 1 |
| Diana | Sales | 70000 | 1 |
| Eve | Sales | 65000 | 2 |
| Frank | Sales | 65000 | 3 |
| Grace | Sales | 60000 | 4 |
Each department starts its own independent numbering sequence. This pattern is the foundation for top-N per group queries, which we will cover below.
RANK()
RANK() assigns a rank to each row within its partition based on the ORDER BY column. Unlike ROW_NUMBER(), when two or more rows have the same value, RANK() gives them the same rank. However, it then skips the subsequent rank number(s) to account for the tied positions.
Syntax
RANK() OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC], ...
)
Example
SELECT
name,
department,
salary,
RANK() OVER (ORDER BY salary DESC) AS salary_rank
FROM employees;
Output:
| name | department | salary | salary_rank |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 2 |
| Hank | Marketing | 72000 | 4 |
| Diana | Sales | 70000 | 5 |
| Eve | Sales | 65000 | 6 |
| Frank | Sales | 65000 | 6 |
| Grace | Sales | 60000 | 8 |
Key observations:
- Bob and Charlie both earn 90000 and both receive rank 2.
- Rank 3 is skipped entirely. The next rank after the tie is 4 (Hank).
- Eve and Frank both earn 65000 and both receive rank 6.
- Rank 7 is skipped. Grace gets rank 8.
Think of it like a race: if two runners tie for 2nd place, the next runner is in 4th place, not 3rd. There is no 3rd place.
RANK() with PARTITION BY
SELECT
name,
department,
salary,
RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS dept_rank
FROM employees;
Output:
| name | department | salary | dept_rank |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 2 |
| Hank | Marketing | 72000 | 1 |
| Diana | Sales | 70000 | 1 |
| Eve | Sales | 65000 | 2 |
| Frank | Sales | 65000 | 2 |
| Grace | Sales | 60000 | 4 |
Within Sales, Eve and Frank tie at rank 2, so rank 3 is skipped and Grace receives rank 4.
DENSE_RANK()
DENSE_RANK() works like RANK() in that tied rows receive the same rank. The difference is that DENSE_RANK() does not skip any rank numbers. The next distinct value always gets the very next integer.
Syntax
DENSE_RANK() OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC], ...
)
Example
SELECT
name,
department,
salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_salary_rank
FROM employees;
Output:
| name | department | salary | dense_salary_rank |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 2 |
| Hank | Marketing | 72000 | 3 |
| Diana | Sales | 70000 | 4 |
| Eve | Sales | 65000 | 5 |
| Frank | Sales | 65000 | 5 |
| Grace | Sales | 60000 | 6 |
Key observations:
- Bob and Charlie still share rank 2 (same as
RANK()). - Hank gets rank 3, not 4. No gap.
- Eve and Frank share rank 5.
- Grace gets rank 6, not 8. No gap.
The maximum DENSE_RANK() value tells you how many distinct salary levels exist in the data.
Side-by-Side Comparison
Let's put all three functions together in a single query to see the differences clearly:
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC, name ASC) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;
Output:
| name | department | salary | row_num | rank | dense_rank |
|---|---|---|---|---|---|
| Alice | Engineering | 95000 | 1 | 1 | 1 |
| Bob | Engineering | 90000 | 2 | 2 | 2 |
| Charlie | Engineering | 90000 | 3 | 2 | 2 |
| Hank | Marketing | 72000 | 4 | 4 | 3 |
| Diana | Sales | 70000 | 5 | 5 | 4 |
| Eve | Sales | 65000 | 6 | 6 | 5 |
| Frank | Sales | 65000 | 7 | 6 | 5 |
| Grace | Sales | 60000 | 8 | 8 | 6 |
| Function | Ties get same number? | Gaps after ties? | Max value equals... |
|---|---|---|---|
ROW_NUMBER() | No | N/A | Total row count |
RANK() | Yes | Yes | Total row count |
DENSE_RANK() | Yes | No | Number of distinct values |
ROW_NUMBER(): When you need every row to have a unique number, regardless of ties. Best for pagination, deduplication, and top-N per group.RANK(): When ties should share the same position and subsequent positions should reflect the actual count of rows above. Best for competition-style ranking (e.g., leaderboards).DENSE_RANK(): When ties should share the same position but you do not want gaps. Best for finding the Nth distinct value (e.g., "the 3rd highest salary").
Use Case: Top-N Per Group
One of the most common interview questions and real-world requirements is: "Find the top N records in each group." This is where ROW_NUMBER() (or sometimes RANK() / DENSE_RANK()) truly shines.
Problem: Find the Highest-Paid Employee in Each Department
SELECT * FROM (
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS rn
FROM employees
) ranked
WHERE rn = 1;
Output:
| name | department | salary | rn |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Hank | Marketing | 72000 | 1 |
| Diana | Sales | 70000 | 1 |
This pattern works by:
- Assigning a row number within each department, ordered by salary descending.
- Wrapping it in a subquery.
- Filtering only rows where the row number is 1.
Window functions cannot appear in a WHERE clause directly. You must always wrap the query in a subquery or a CTE first.
-- This will FAIL
SELECT name, department, salary
FROM employees
WHERE ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) = 1;
Error: window functions are not allowed in WHERE
Top-N Using a CTE (Cleaner Syntax)
A Common Table Expression (CTE) makes the top-N pattern more readable:
WITH ranked_employees AS (
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, name ASC
) AS rn
FROM employees
)
SELECT name, department, salary
FROM ranked_employees
WHERE rn <= 2;
Output:
| name | department | salary |
|---|---|---|
| Alice | Engineering | 95000 |
| Bob | Engineering | 90000 |
| Hank | Marketing | 72000 |
| Diana | Sales | 70000 |
| Eve | Sales | 65000 |
This returns the top 2 earners per department. Marketing has only one employee, so only Hank is returned.
ROW_NUMBER vs RANK for Top-N: Which to Choose?
The choice depends on how you want to handle ties.
Using ROW_NUMBER() with WHERE rn <= 2 returns exactly 2 rows per group (or fewer if the group has fewer rows), even if there are ties.
Using RANK() with WHERE rnk <= 2 might return more than 2 rows per group if there are ties at the boundary:
WITH ranked_employees AS (
SELECT
name,
department,
salary,
RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS rnk
FROM employees
)
SELECT name, department, salary, rnk
FROM ranked_employees
WHERE rnk <= 2;
Output:
| name | department | salary | rnk |
|---|---|---|---|
| Alice | Engineering | 95000 | 1 |
| Bob | Engineering | 90000 | 2 |
| Charlie | Engineering | 90000 | 2 |
| Hank | Marketing | 72000 | 1 |
| Diana | Sales | 70000 | 1 |
| Eve | Sales | 65000 | 2 |
| Frank | Sales | 65000 | 2 |
Engineering now returns 3 rows (Alice, Bob, and Charlie) because Bob and Charlie are tied at rank 2. Sales returns 3 rows as well. Use RANK() when you want to include all tied records at the boundary.
Use Case: Deduplication
Another extremely common scenario is removing duplicate rows. When a table has duplicate entries and you need to keep only one version of each, ROW_NUMBER() is the go-to tool.
The Problem
Consider a user_logins table with accidental duplicates:
| id | login_date | source | |
|---|---|---|---|
| 1 | alice@example.com | 2024-03-01 | web |
| 2 | alice@example.com | 2024-03-01 | web |
| 3 | bob@example.com | 2024-03-02 | mobile |
| 4 | bob@example.com | 2024-03-02 | mobile |
| 5 | bob@example.com | 2024-03-05 | web |
CREATE TABLE user_logins (
id INT PRIMARY KEY,
email VARCHAR(100) NOT NULL,
login_date DATE NOT NULL,
source VARCHAR(20) NOT NULL
);
INSERT INTO user_logins (id, email, login_date, source) VALUES
(1, 'alice@example.com', '2024-03-01', 'web'),
(2, 'alice@example.com', '2024-03-01', 'web'),
(3, 'bob@example.com', '2024-03-02', 'mobile'),
(4, 'bob@example.com', '2024-03-02', 'mobile'),
(5, 'bob@example.com', '2024-03-05', 'web');
Selecting Only Unique Rows
WITH deduplicated AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY email, login_date, source
ORDER BY id ASC
) AS rn
FROM user_logins
)
SELECT id, email, login_date, source
FROM deduplicated
WHERE rn = 1;
Output:
| id | login_date | source | |
|---|---|---|---|
| 1 | alice@example.com | 2024-03-01 | web |
| 3 | bob@example.com | 2024-03-02 | mobile |
| 5 | bob@example.com | 2024-03-05 | web |
How this works:
PARTITION BY email, login_date, sourcegroups rows that are exact duplicates together.ORDER BY id ASCdetermines which row to keep (the one with the smallestid).- Filtering
WHERE rn = 1keeps only the first occurrence and discards the rest.
Deleting Duplicates (SQL Server / PostgreSQL)
In some databases, you can delete duplicates directly using a CTE:
-- PostgreSQL syntax
WITH deduplicated AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY email, login_date, source
ORDER BY id ASC
) AS rn
FROM user_logins
)
DELETE FROM user_logins
WHERE id IN (
SELECT id FROM deduplicated WHERE rn > 1
);
This removes all rows where rn > 1, keeping only the first occurrence of each duplicate group.
Always run the SELECT version of a deduplication query first to verify which rows will be kept and which will be removed before executing any DELETE statement. Deletes are permanent.
Use Case: Finding the Nth Highest Value
DENSE_RANK() is the best choice when the question is "find the Nth distinct value" because it does not skip numbers.
Problem: Find the 3rd Highest Salary
WITH salary_ranks AS (
SELECT
name,
salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
FROM employees
)
SELECT name, salary
FROM salary_ranks
WHERE dr = 3;
Output:
| name | salary |
|---|---|
| Hank | 72000 |
The distinct salary values in descending order are: 95000 (rank 1), 90000 (rank 2), 72000 (rank 3), 70000 (rank 4), 65000 (rank 5), 60000 (rank 6). DENSE_RANK() correctly identifies 72000 as the 3rd highest.
If you had used RANK() instead, the 3rd rank would be skipped (because 90000 is tied at rank 2, so RANK() jumps to 4), and WHERE dr = 3 would return no rows.
-- Using RANK() for comparison
WITH salary_ranks AS (
SELECT
name,
salary,
RANK() OVER (ORDER BY salary DESC) AS r
FROM employees
)
SELECT name, salary
FROM salary_ranks
WHERE r = 3;
Output:
| name | salary |
|---|---|
| (empty result set, 0 rows) |
This is why DENSE_RANK() is the correct function for "Nth highest/lowest" questions.
Common Mistakes to Avoid
Mistake 1: Missing ORDER BY in OVER()
All three ranking functions require ORDER BY to produce meaningful results:
-- Meaningless: no ORDER BY
SELECT name, salary,
ROW_NUMBER() OVER () AS rn
FROM employees;
Without ORDER BY, the database assigns row numbers in an arbitrary order. The query will run, but the numbering is unpredictable and useless.
Fix: Always specify what the ranking should be based on.
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees;
Mistake 2: Using ROW_NUMBER() When Ties Matter
If your business logic requires that tied values receive the same position, ROW_NUMBER() will silently give incorrect results because it always assigns unique numbers.
-- Problem: Bob and Charlie earn the same but get different row numbers
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS position
FROM employees;
| name | salary | position |
|---|---|---|
| Alice | 95000.00 | 1 |
| Bob | 90000.00 | 2 |
| Charlie | 90000.00 | 3 |
| Hank | 72000.00 | 4 |
| Diana | 70000.00 | 5 |
| Eve | 65000.00 | 6 |
| Frank | 65000.00 | 7 |
| Grace | 60000.00 | 8 |
If Bob and Charlie should be ranked equally, use RANK() or DENSE_RANK() instead:
SELECT name, salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS position
FROM employees;
| name | salary | position |
|---|---|---|
| Alice | 95000.00 | 1 |
| Bob | 90000.00 | 2 |
| Charlie | 90000.00 | 2 |
| Hank | 72000.00 | 3 |
| Diana | 70000.00 | 4 |
| Eve | 65000.00 | 5 |
| Frank | 65000.00 | 5 |
| Grace | 60000.00 | 6 |
Mistake 3: Applying DISTINCT with Window Functions
Adding DISTINCT to a query with window functions often produces unexpected results because DISTINCT is applied after the window function has already been evaluated:
-- Probably not what you want
SELECT DISTINCT
department,
ROW_NUMBER() OVER (ORDER BY department) AS rn
FROM employees;
Since ROW_NUMBER() gives every row a unique number, every combination of (department, rn) is already unique, so DISTINCT removes nothing. If your goal is to list unique departments with a number, use a subquery or CTE with a GROUP BY or DISTINCT first, then apply the window function.
-- Correct approach
SELECT
department,
ROW_NUMBER() OVER (ORDER BY department) AS rn
FROM (
SELECT DISTINCT department FROM employees
) unique_depts;
Output:
| department | rn |
|---|---|
| Engineering | 1 |
| Marketing | 2 |
| Sales | 3 |
Performance Considerations
Ranking functions require the database to sort data within each partition. Keep these points in mind:
- Indexes matter. If you frequently rank by a specific column (e.g.,
salary), an index on that column (or a composite index including thePARTITION BYcolumns) can significantly speed up the query. - Partition size matters. Very large partitions mean more data to sort. If performance is a concern, check whether your
PARTITION BYclause creates reasonably sized groups. - Avoid redundant window definitions. If you use multiple window functions with the same
OVER()clause, some databases optimize them to share a single sort pass. You can also use a named window (in PostgreSQL and MySQL 8+):
SELECT
name,
salary,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rnk,
DENSE_RANK() OVER w AS drnk
FROM employees
WINDOW w AS (ORDER BY salary DESC);
| name | salary | rn | rnk | drnk |
|---|---|---|---|---|
| Alice | 95000.00 | 1 | 1 | 1 |
| Bob | 90000.00 | 2 | 2 | 2 |
| Charlie | 90000.00 | 3 | 2 | 2 |
| Hank | 72000.00 | 4 | 4 | 3 |
| Diana | 70000.00 | 5 | 5 | 4 |
| Eve | 65000.00 | 6 | 6 | 5 |
| Frank | 65000.00 | 7 | 6 | 5 |
| Grace | 60000.00 | 8 | 8 | 6 |
This avoids repeating the same OVER() definition three times and can improve both readability and performance.
Summary
ROW_NUMBER(), RANK(), and DENSE_RANK() are three of the most frequently used SQL window functions for ordering and ranking data. Here is a final recap:
ROW_NUMBER()assigns a unique sequential number. Every row gets a different number, even when values are tied. Best for top-N per group, pagination, and deduplication.RANK()assigns the same rank to tied values but skips subsequent numbers. Best for competition-style ranking where position reflects how many rows are above.DENSE_RANK()assigns the same rank to tied values with no gaps. Best for finding the Nth distinct value in a dataset.
All three require ORDER BY inside OVER() and cannot be used in WHERE directly. For filtering, always wrap them in a subquery or CTE.
A simple mental model: imagine a classroom of students sorted by test score. ROW_NUMBER() is the seat number (every student sits in a different seat). RANK() is the competition placement (ties share a place, next place skips). DENSE_RANK() is the "distinct score position" (ties share, no skipping).