SQL RANK vs DENSE_RANK vs ROW_NUMBER Example

SQL RANK vs DENSE_RANK vs ROW_NUMBER Example

If you have ever needed to rank rows in SQL — finding the top performer in each department, assigning position numbers to sales results, or identifying the Nth highest value — you have encountered three functions that look almost identical but behave very differently:

  • ROW_NUMBER()
  • RANK()
  • DENSE_RANK()

All three assign numbers to rows based on an ordering you define. But how they handle duplicate values is completely different and getting that wrong produces silently incorrect results that are very difficult to debug.

In this guide, we will break down all three functions with a real dataset, clear side-by-side comparisons, and practical examples so you always know which one to reach for.

What Are Ranking Window Functions?

All three are window functions — they operate on a set of rows related to the current row (called a window) and return a value for each row without collapsing the result like GROUP BY does.

Basic Syntax (Same for All Three)

sql

FUNCTION_NAME() OVER (
    PARTITION BY partition_column
    ORDER BY order_column
)
  • PARTITION BY — Optional. Restarts ranking for each group
  • ORDER BY — Required. Defines what order to rank by

The only difference between the three functions is what number they assign when two or more rows have the same ORDER BY value — the tie-breaking behavior.

Setting Up the Real Dataset

sql

CREATE TABLE sales_performance (
    employee_id   INT,
    name          VARCHAR(50),
    department    VARCHAR(50),
    region        VARCHAR(50),
    quarterly_sales DECIMAL(12, 2)
);

INSERT INTO sales_performance VALUES
(1,  'Alice',   'Electronics', 'North', 95000),
(2,  'Bob',     'Electronics', 'South', 82000),
(3,  'Charlie', 'Electronics', 'North', 95000),
(4,  'Diana',   'Electronics', 'East',  78000),
(5,  'Eve',     'Furniture',   'North', 67000),
(6,  'Frank',   'Furniture',   'South', 71000),
(7,  'Grace',   'Furniture',   'East',  71000),
(8,  'Henry',   'Furniture',   'West',  59000),
(9,  'Iris',    'Clothing',    'North', 88000),
(10, 'Jack',    'Clothing',    'South', 75000),
(11, 'Karen',   'Clothing',    'East',  88000),
(12, 'Leo',     'Clothing',    'West',  62000);

Key observation: Several employees have identical sales figures.

  • Alice and Charlie both have 95,000 (Electronics)
  • Frank and Grace both have 71,000 (Furniture)
  • Iris and Karen both have 88,000 (Clothing)

These ties are exactly what reveal the difference between the three functions.

ROW_NUMBER() — Always Unique, No Ties

ROW_NUMBER() assigns a completely unique sequential number to every row — regardless of whether values are the same. No two rows ever get the same number.

When rows tie on the ORDER BY value, ROW_NUMBER() breaks the tie arbitrarily — the database engine decides which tied row gets which number, and the result can vary between executions unless you add a tiebreaker column to ORDER BY.

sql

SELECT
    name,
    department,
    quarterly_sales,
    ROW_NUMBER() OVER (ORDER BY quarterly_sales DESC) AS row_num
FROM sales_performance
ORDER BY quarterly_sales DESC;

Result:

namedepartmentquarterly_salesrow_num
AliceElectronics950001
CharlieElectronics950002
IrisClothing880003
KarenClothing880004
BobElectronics820005
JackClothing750006
DianaElectronics780007
FrankFurniture710008
GraceFurniture710009
EveFurniture6700010
LeoClothing6200011
HenryFurniture5900012

Alice and Charlie both have 95,000 but they receive row numbers 1 and 2 respectively. Iris and Karen both have 88,000 — they receive 3 and 4. Every row gets a unique number regardless of ties.

When to Use ROW_NUMBER()

  • When you need exactly one row per entity — like the most recent record per customer or the latest version of a document
  • When you want to paginate results — get rows 11 through 20 of a sorted list
  • When you need to deduplicate — assign 1 to one row per group, filter for 1
  • When ties genuinely should not produce the same number

RANK() — Ties Get Same Number, Gaps After Ties

RANK() assigns the same number to tied rows but then skips numbers equal to the count of tied rows before continuing the sequence.

If two rows tie at position 1, both get rank 1 and the next rank is 3, not 2. The number 2 is skipped. This creates gaps in the ranking sequence.

sql

SELECT
    name,
    department,
    quarterly_sales,
    RANK() OVER (ORDER BY quarterly_sales DESC) AS rnk
FROM sales_performance
ORDER BY quarterly_sales DESC;

Result:

namedepartmentquarterly_salesrnk
AliceElectronics950001
CharlieElectronics950001
IrisClothing880003
KarenClothing880003
BobElectronics820005
DianaElectronics780006
JackClothing750007
FrankFurniture710008
GraceFurniture710008
EveFurniture6700010
LeoClothing6200011
HenryFurniture5900012

Alice and Charlie both get rank 1. The next rank is 3 (not 2) because position 2 was occupied by the second tied row. Iris and Karen both get rank 3. The next rank is 5 (not 4). Frank and Grace both get rank 8. The next rank is 10 (not 9).

Notice the gaps: 1, 1, 3, 3, 5, 6, 7, 8, 8, 10, 11, 12. Ranks 2, 4, and 9 are completely missing.

When to Use RANK()

  • When you want ties to be acknowledged with the same position and gaps feel natural
  • In competition-style rankings — if two athletes tie for gold, there is no silver medal (rank 3 is next)
  • When you want to find rows at a specific rank position while correctly handling ties
  • Leaderboard displays where tied positions should show the same rank number

DENSE_RANK() — Ties Get Same Number, No Gaps

DENSE_RANK() also assigns the same number to tied rows but unlike RANK(), it does NOT skip numbers. The sequence is always continuous — no gaps, no skipped positions.

sql

SELECT
    name,
    department,
    quarterly_sales,
    DENSE_RANK() OVER (ORDER BY quarterly_sales DESC) AS dense_rnk
FROM sales_performance
ORDER BY quarterly_sales DESC;

Result:

namedepartmentquarterly_salesdense_rnk
AliceElectronics950001
CharlieElectronics950001
IrisClothing880002
KarenClothing880002
BobElectronics820003
DianaElectronics780004
JackClothing750005
FrankFurniture710006
GraceFurniture710006
EveFurniture670007
LeoClothing620008
HenryFurniture590009

Alice and Charlie both get dense rank 1. Iris and Karen both get dense rank 2 (not 3 as RANK() gave them). The sequence is perfectly continuous: 1, 1, 2, 2, 3, 4, 5, 6, 6, 7, 8, 9 — no gaps at all.

When to Use DENSE_RANK()

  • When you need continuous ranking with no gaps — “how many distinct performance levels are above this one?”
  • Finding the Nth highest valueWHERE dense_rnk = 3 reliably returns the third-highest distinct value
  • Salary banding — how many distinct salary levels exist, what band is each employee in
  • Any scenario where gaps in ranking would be confusing or cause logic errors

All Three Side by Side — The Critical Comparison

This is the most important table in this guide — seeing all three functions on the same data makes the differences unmistakable.

sql

SELECT
    name,
    department,
    quarterly_sales,
    ROW_NUMBER() OVER (ORDER BY quarterly_sales DESC) AS row_num,
    RANK()       OVER (ORDER BY quarterly_sales DESC) AS rnk,
    DENSE_RANK() OVER (ORDER BY quarterly_sales DESC) AS dense_rnk
FROM sales_performance
ORDER BY quarterly_sales DESC;

Result:

namedepartmentquarterly_salesrow_numrnkdense_rnk
AliceElectronics95000111
CharlieElectronics95000211
IrisClothing88000332
KarenClothing88000432
BobElectronics82000553
DianaElectronics78000664
JackClothing75000775
FrankFurniture71000886
GraceFurniture71000986
EveFurniture6700010107
LeoClothing6200011118
HenryFurniture5900012129

Reading the differences at Alice and Charlie (tied at 95,000):

  • ROW_NUMBER: 1 and 2 — arbitrarily different
  • RANK: both 1 — next is 3 (skips 2)
  • DENSE_RANK: both 1 — next is 2 (no skip)

Reading the differences at Iris and Karen (tied at 88,000):

  • ROW_NUMBER: 3 and 4 — arbitrarily different
  • RANK: both 3 — next is 5 (skips 4)
  • DENSE_RANK: both 2 — next is 3 (no skip)

Using PARTITION BY — Ranking Within Groups

Add PARTITION BY to restart the ranking independently for each group — ranking employees within their department rather than across all departments.

sql

SELECT
    name,
    department,
    quarterly_sales,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY quarterly_sales DESC) AS row_num,
    RANK()       OVER (PARTITION BY department ORDER BY quarterly_sales DESC) AS rnk,
    DENSE_RANK() OVER (PARTITION BY department ORDER BY quarterly_sales DESC) AS dense_rnk
FROM sales_performance
ORDER BY department, quarterly_sales DESC;

Result:

namedepartmentquarterly_salesrow_numrnkdense_rnk
IrisClothing88000111
KarenClothing88000211
JackClothing75000332
LeoClothing62000443
AliceElectronics95000111
CharlieElectronics95000211
BobElectronics82000332
DianaElectronics78000443
FrankFurniture71000111
GraceFurniture71000211
EveFurniture67000332
HenryFurniture59000443

Each department restarts from 1. Iris and Karen share rank 1 within Clothing. Alice and Charlie share rank 1 within Electronics. Frank and Grace share rank 1 within Furniture.

Practical Example 1: Get Top Performer Per Department

Use ROW_NUMBER() with PARTITION BY to get exactly one top performer per department — breaking ties arbitrarily.

sql

WITH ranked AS (
    SELECT
        name,
        department,
        quarterly_sales,
        ROW_NUMBER() OVER (
            PARTITION BY department
            ORDER BY quarterly_sales DESC
        ) AS row_num
    FROM sales_performance
)
SELECT name, department, quarterly_sales
FROM ranked
WHERE row_num = 1;

Result:

namedepartmentquarterly_sales
IrisClothing88000
AliceElectronics95000
FrankFurniture71000

Only one row per department — even when there are ties. ROW_NUMBER() arbitrarily picks one tied employee.

If you want ALL tied top performers — use RANK() or DENSE_RANK() instead:

sql

WITH ranked AS (
    SELECT
        name,
        department,
        quarterly_sales,
        DENSE_RANK() OVER (
            PARTITION BY department
            ORDER BY quarterly_sales DESC
        ) AS dense_rnk
    FROM sales_performance
)
SELECT name, department, quarterly_sales
FROM ranked
WHERE dense_rnk = 1;

Result:

namedepartmentquarterly_sales
IrisClothing88000
KarenClothing88000
AliceElectronics95000
CharlieElectronics95000
FrankFurniture71000
GraceFurniture71000

All tied top performers appear — the correct result when ties should be equally recognized.

Practical Example 2: Find the Nth Highest Sales Value

DENSE_RANK() is the correct function for finding the Nth highest distinct value — because it has no gaps that could make your WHERE clause return nothing.

sql

-- Find the 3rd highest distinct sales figure
WITH ranked AS (
    SELECT
        name,
        department,
        quarterly_sales,
        DENSE_RANK() OVER (ORDER BY quarterly_sales DESC) AS dense_rnk
    FROM sales_performance
)
SELECT name, department, quarterly_sales
FROM ranked
WHERE dense_rnk = 3;

Result:

namedepartmentquarterly_sales
BobElectronics82000

The 3rd highest distinct value is 82,000 (after 95,000 and 88,000). DENSE_RANK() reliably returns this — RANK() would return the same result here, but on different data the gaps in RANK() can cause WHERE rnk = N to return nothing.

Practical Example 3: Deduplication With ROW_NUMBER()

One of the most common real-world uses of ROW_NUMBER() — removing duplicate records by keeping only one row per entity.

sql

-- Simulate duplicate records
WITH duplicated_data AS (
    SELECT name, department, quarterly_sales FROM sales_performance
    UNION ALL
    SELECT name, department, quarterly_sales FROM sales_performance
    WHERE name IN ('Alice', 'Bob')  -- Alice and Bob appear twice
),
deduplicated AS (
    SELECT
        name,
        department,
        quarterly_sales,
        ROW_NUMBER() OVER (
            PARTITION BY name
            ORDER BY quarterly_sales DESC
        ) AS row_num
    FROM duplicated_data
)
SELECT name, department, quarterly_sales
FROM deduplicated
WHERE row_num = 1;

ROW_NUMBER() assigns 1 to the first occurrence of each name. Filtering for row_num = 1 keeps exactly one row per employee — removing the duplicates.

Practical Example 4: Percentile Ranking With NTILE

As a bonus — NTILE() divides rows into N equal buckets. Combined with RANK/DENSE_RANK it enables percentile-based segmentation.

sql

SELECT
    name,
    department,
    quarterly_sales,
    DENSE_RANK() OVER (ORDER BY quarterly_sales DESC) AS sales_rank,
    NTILE(4) OVER (ORDER BY quarterly_sales DESC) AS quartile,
    CASE NTILE(4) OVER (ORDER BY quarterly_sales DESC)
        WHEN 1 THEN 'Top 25%'
        WHEN 2 THEN 'Upper Middle'
        WHEN 3 THEN 'Lower Middle'
        WHEN 4 THEN 'Bottom 25%'
    END AS performance_band
FROM sales_performance
ORDER BY quarterly_sales DESC;

Full Comparison Table

FeatureROW_NUMBER()RANK()DENSE_RANK()
Ties get same number No — always uniqueYesYes
Creates gaps after tiesN/AYes — skips numbersNo — continuous
Sequence always 1,2,3…YesNo (gaps on ties)Yes (no gaps)
Finds Nth highest valueUnreliable with tiesMay skip NBest choice
Gets exactly one row per groupYes No (ties return multiple)No (ties return multiple)
DeduplicationYes — ideal NoNo
Competition-style rankingNoYesNo gaps
PaginationYes NoNo

Database Compatibility

DatabaseROW_NUMBER()RANK()DENSE_RANK()
SQL Server 2005+YesYesYes
PostgreSQL 8.4+YesYesYes
MySQL 8.0+YesYesYes
Oracle 8i+YesYesYes
SQLite 3.25+ YesYesYes
BigQueryYesYesYes
SnowflakeYesYesYes

All three functions are universally supported in modern SQL databases.

Real-World Use Cases

Sales Leaderboards — RANK() or DENSE_RANK() with PARTITION BY territory or product line. Tied performers at the same position is meaningful — if two reps hit the same quota, both deserve first place recognition.

Employee Performance Reviews — DENSE_RANK() within department to identify top, mid, and lower performers without gaps confusing the classification.

Deduplication in ETL Pipelines — ROW_NUMBER() with PARTITION BY entity ID and ORDER BY timestamp descending — filter for row_num = 1 to keep the most recent record per entity.

Pagination in APIs — ROW_NUMBER() over a sorted result, then WHERE row_num BETWEEN (page-1)page_size+1 AND pagepage_size to return a specific page of results.

Finding Nth Highest Salary — DENSE_RANK() with WHERE dense_rnk = N — the most reliable approach that handles duplicates correctly without gaps.

Customer Segmentation — NTILE() or DENSE_RANK() over purchase frequency or lifetime value to assign customers to tiers (Platinum, Gold, Silver, Bronze).

Common Mistakes to Avoid

  • Using RANK() to find Nth highest value — If ties exist at ranks before N, RANK() skips numbers and WHERE rnk = N may return nothing. Always use DENSE_RANK() for Nth value queries
  • Using ROW_NUMBER() when ties should be equal — If your business logic says two tied employees should share first place, ROW_NUMBER() unfairly gives one person rank 1 and the other rank 2. Use RANK() or DENSE_RANK() instead
  • Not adding a tiebreaker to ROW_NUMBER() — When using ROW_NUMBER() for deduplication or pagination, the ordering for tied rows is arbitrary. Add a secondary column (like ID or timestamp) to ORDER BY to make the result deterministic: ORDER BY sales DESC, employee_id ASC
  • Forgetting PARTITION BY when ranking within groups — Without PARTITION BY, all rows are ranked together globally. A department-level leaderboard needs PARTITION BY department to restart rankings per department
  • Confusing the absence of gaps in DENSE_RANK with RANK — RANK() has gaps. DENSE_RANK() does not. Mixing them up in a query that filters by rank number produces wrong results without any error message

Quick Reference Cheat Sheet

TaskUse This
Unique number per row, no tiesROW_NUMBER()
Same rank for ties, gaps afterRANK()
Same rank for ties, no gapsDENSE_RANK()
Get exactly 1 row per groupROW_NUMBER() + WHERE row_num = 1
Get ALL tied top performersDENSE_RANK() + WHERE dense_rnk = 1
Find Nth highest valueDENSE_RANK() + WHERE dense_rnk = N
Deduplicate recordsROW_NUMBER() + WHERE row_num = 1
Paginate resultsROW_NUMBER() + WHERE BETWEEN
Department-level rankingAny + PARTITION BY department

ROW_NUMBER(), RANK(), and DENSE_RANK() are three of the most used window functions in SQL and the difference between them comes down entirely to one question: what should happen when rows tie?

Here is the simplest memory aid:

  • ROW_NUMBER — No ties ever. Every row gets a unique number. Use for deduplication and pagination.
  • RANK — Ties get same number. Gaps appear after ties. Use for competition-style rankings.
  • DENSE_RANK — Ties get same number. No gaps. Use for Nth value queries and continuous ranking.

When you are unsure, DENSE_RANK() is usually the safest choice — it handles ties correctly and never produces gaps that break downstream filtering logic.

FAQs

What is the difference between RANK and DENSE_RANK in SQL?

Both assign the same rank number to tied rows. The difference is what happens next. RANK() skips numbers after ties — if two rows tie at rank 1, the next rank is 3. DENSE_RANK() never skips — if two rows tie at rank 1, the next rank is 2.

When should I use ROW_NUMBER instead of RANK?

Use ROW_NUMBER when you need exactly one row per entity — for deduplication, pagination, or selecting a single record per group. Use RANK or DENSE_RANK when tied rows should receive the same position number.

Which ranking function should I use for finding the Nth highest value?

Always use DENSE_RANK(). RANK() creates gaps that can cause WHERE rnk = N to return nothing. DENSE_RANK() produces a continuous sequence where WHERE dense_rnk = N reliably returns the Nth highest distinct value.

Does ROW_NUMBER produce deterministic results for tied rows?

Not by default. When rows tie on the ORDER BY column, the database engine assigns numbers arbitrarily and results can vary between executions. Add a secondary tiebreaker column (like a unique ID) to ORDER BY to make the result deterministic.

Can I use these functions without PARTITION BY?

Yes. Without PARTITION BY, the ranking applies across all rows in the entire result set. Add PARTITION BY when you want ranking to restart independently for each group — like within each department or each region.

Do RANK, DENSE_RANK, and ROW_NUMBER work in all databases?

Yes. All three are ANSI SQL standard window functions supported in SQL Server, PostgreSQL, MySQL 8.0+, Oracle, SQLite 3.25+, BigQuery, and Snowflake.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top