ROW_NUMBER vs RANK vs DENSE_RANK in SQL (Tie Examples)
Compare ROW_NUMBER vs RANK vs DENSE_RANK in SQL. See exact tie handling (1,2,3 vs 1,2,2,4 vs 1,2,2,3), deduplication patterns, and interview query examples.
In technical SQL interviews and day-to-day analytics, ranking questions are everywhere: "Find the top 3 highest-priced products per category," "Deduplicate user logs keeping only the latest event," and "Find the second-highest salary in each department."
Candidates often stumble not because they don't know the syntax, but because they pick the wrong ranking function or fail to account for ties. Choosing RANK() when you need ROW_NUMBER() can accidentally return duplicates, while using ROW_NUMBER() without a tiebreaker can produce non-deterministic results that change between runs.
This guide breaks down the mechanics of ROW_NUMBER(), RANK(), and DENSE_RANK(), visualizes their tie-handling side by side, and demonstrates the classic production deduplication pattern using a real e-commerce PostgreSQL dataset with 2,000 orders and 800 reviews. For broader window function coverage, read our SQL Window Functions Guide and SQL CTE Guide.
1. The Core Idea: The OVER() Clause
Standard GROUP BY collapses multiple rows into a single summary row, destroying row-level details like product names or customer IDs. Window functions solve this by computing calculations across a set of rows while keeping every individual row intact.
The keyword that activates window calculations is OVER().
Preserving Detail with an Empty OVER() Clause
An empty OVER() clause treats the entire table as a single window frame, stamping the grand total on every single row:
SELECT order_id,
total_amount,
ROUND(SUM(total_amount) OVER (), 2) AS grand_total_revenue
FROM orders
ORDER BY order_id
LIMIT 5;The Output
| order_id | total_amount | grand_total_revenue |
|---|---|---|
| 1 | ₹145.77 | ₹782,905.04 |
| 2 | ₹312.40 | ₹782,905.04 |
| 3 | ₹890.15 | ₹782,905.04 |
| 4 | ₹54.20 | ₹782,905.04 |
| 5 | ₹421.80 | ₹782,905.04 |
GROUP BY would have collapsed all 2,000 orders into 1 summary row. The window function appends the ₹782,905.04 total alongside each order while preserving all 2,000 individual records.
2. Visualizing Ties: ROW_NUMBER vs RANK vs DENSE_RANK
When ranking rows within a partition, what happens when two or more rows have identical values? Let's compare all three functions on the same dataset.
SCENARIO: Ranking 4 candidates by score (Scores: 100, 90, 90, 80)
┌────────────────┬───────┬────────────┬────────┬────────────┐
│ Candidate │ Score │ ROW_NUMBER │ RANK() │ DENSE_RANK │
├────────────────┼───────┼────────────┼────────┼────────────┤
│ Sarah │ 100 │ 1 │ 1 │ 1 │
│ Alex (Tie) │ 90 │ 2 │ 2 │ 2 │
│ Rohan (Tie) │ 90 │ 3 │ 2 │ 2 │
│ Priya │ 80 │ 4 │ 4 │ 3 │
└────────────────┴───────┴────────────┴────────┴────────────┘
│ │ │
Unique sequential Leaves gap No gaps
(1,2,3,4) (1,2,2,4) (1,2,2,3)
Real E-Commerce Example: Ranking Products in the Books Category
In our products table, the top product in the Books category is Advanced Set 129 at ₹497.19. Further down the list, two distinct book products share the exact same price: ₹447.90.
Let's run RANK(), DENSE_RANK(), and ROW_NUMBER() side by side:
SELECT category,
product_name,
price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS row_num,
RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS dense_rnk
FROM products
WHERE category = 'Books'
ORDER BY price DESC
LIMIT 7;The Live Output
| product_name | price | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Advanced Set 129 | ₹497.19 | 1 | 1 | 1 |
| Deluxe Gadget 842 | ₹482.50 | 2 | 2 | 2 |
| Essential Kit 311 | ₹460.00 | 3 | 3 | 3 |
| Basic Bundle 904 | ₹447.90 | 4 | 4 | 4 |
| Pro System 512 | ₹447.90 | 5 | 4 | 4 |
| Premium Widget 721 | ₹431.10 | 6 | 6 | 5 |
| Classic Pack 108 | ₹412.00 | 7 | 7 | 6 |
The Critical Differences
- The Tie at ₹447.90: Both
Basic Bundle 904andPro System 512receive rank 4 in bothRANK()andDENSE_RANK(). - The Subsequent Row (
Premium Widget 721):RANK()jumps straight to 6, skipping 5. It accounts for the 2 items tied at rank 4.DENSE_RANK()assigns 5, closing the gap.ROW_NUMBER()arbitrarily numbered them 4 and 5 sequentially.
3. The Production Deduplication Pattern: ROW_NUMBER + rn = 1
The single most common real-world use case for ROW_NUMBER() is deduplicating data—keeping only the latest or most complete row per business key.
The Problem: Repeat Customer Reviews
In our reviews table, there are 800 total reviews, but only 735 distinct (customer_id, product_id) pairs. That means 65 rows represent repeat reviews submitted by the same customer for the same product.
Our business goal: Retain only the most recent review for every customer-product pair.
Step 1: Assigning Row Numbers with a Tiebreaker
SELECT review_id,
customer_id,
product_id,
rating,
review_date,
ROW_NUMBER() OVER (
PARTITION BY customer_id, product_id
ORDER BY review_date DESC, review_id DESC
) AS rn
FROM reviews;Why the Secondary Tiebreaker (review_id DESC) Matters
If a customer submitted two reviews on the exact same date (review_date), ORDER BY review_date DESC alone is ambiguous. Adding review_id DESC guarantees deterministic results: the higher ID (created later) will always be assigned rn = 1. Without a tiebreaker, Postgres may return different rows across query runs.
Step 2: Filtering with a Common Table Expression (CTE)
Because window functions cannot be evaluated directly inside a WHERE clause (due to SQL query execution order), wrap the ranking query in a CTE:
WITH ranked_reviews AS (
SELECT review_id,
customer_id,
product_id,
rating,
review_date,
ROW_NUMBER() OVER (
PARTITION BY customer_id, product_id
ORDER BY review_date DESC, review_id DESC
) AS rn
FROM reviews
)
SELECT review_id,
customer_id,
product_id,
rating,
review_date
FROM ranked_reviews
WHERE rn = 1
ORDER BY review_id;The Result
- Input Table: 800 reviews
- Output Table: Exactly 735 clean, deduplicated rows
- Discarded: 65 outdated duplicate reviews
4. Top-N per Group: Choosing Between RANK and DENSE_RANK
Suppose management asks: "Give me the 3 highest distinct product prices in each category."
If you use ROW_NUMBER(), categories with price ties will return fewer than 3 distinct price tiers. If you use RANK(), a tie at rank 2 skips rank 3, causing a WHERE rnk <= 3 filter to miss valid items.
DENSE_RANK() is the mathematically correct tool for Top-N distinct benchmark tiers.
WITH category_price_ranks AS (
SELECT category,
product_name,
price,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY price DESC
) AS price_tier
FROM products
WHERE category IS NOT NULL
)
SELECT category,
product_name,
price,
price_tier
FROM category_price_ranks
WHERE price_tier <= 3
ORDER BY category, price_tier, price DESC;This guarantees every category displays all products belonging to the top 3 highest price tiers, regardless of how many items share identical prices.
5. Running Totals with SUM() OVER (ORDER BY)
Window functions are not limited to rankings. Adding an ORDER BY inside OVER() transforms basic aggregates into cumulative running calculations.
Cumulative Monthly Revenue
Let's compute monthly revenue alongside the cumulative running total across time:
WITH monthly_revenue AS (
SELECT DATE_TRUNC('month', order_date)::date AS month,
SUM(total_amount) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY month
)
SELECT month,
ROUND(revenue, 2) AS monthly_rev,
ROUND(SUM(revenue) OVER (ORDER BY month), 2) AS cumulative_revenue
FROM monthly_revenue
ORDER BY month;The Output
| month | monthly_rev | cumulative_revenue |
|---|---|---|
| 2022-01-01 | ₹13,675.76 | ₹13,675.76 |
| 2022-02-01 | ₹10,370.43 | ₹24,046.19 |
| 2022-03-01 | ₹15,820.10 | ₹39,866.29 |
| 2022-04-01 | ₹12,190.50 | ₹52,056.79 |
The OVER (ORDER BY month) frame automatically defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, smoothly accumulating revenue from the first recorded month forward.
6. Feature Comparison Matrix
| Feature / Criteria |
|---|
7. Key Takeaways & Practice
- Pick ROW_NUMBER() whenever you need a single distinct winner (such as deduplication or strict pagination). Always include a secondary unique tiebreaker column in
ORDER BY. - Pick DENSE_RANK() when ranking items into tiers (e.g. 1st, 2nd, and 3rd highest salary) where tied entities should not consume rank numbers.
- Pick RANK() when building traditional competition leaderboards where two 2nd-place finishers mean the next participant is 4th.
- Wrap in a CTE whenever you need to filter ranking output with
WHERE.
Master SQL Window Functions Interactively
Solve real ranking, deduplication, and running total problems with live PostgreSQL validation on Topfolio, or follow our complete Data Analyst Track.
Practice Window Functions FreeTest your window function skills with interactive datasets in the SQL Window Functions Practice Sandbox or prepare for technical interviews with the Topfolio Data Analyst Track. To master query readability and chaining, check out our SQL CTE Guide.
Frequently Asked Questions
What is the difference between ROW_NUMBER(), RANK(), and DENSE_RANK()?
ROW_NUMBER() assigns a distinct sequential integer to every row regardless of ties (1, 2, 3). RANK() assigns tied values the same rank and skips subsequent numbers (1, 2, 2, 4). DENSE_RANK() assigns tied values the same rank without skipping numbers (1, 2, 2, 3).
When should I use ROW_NUMBER() instead of DENSE_RANK()?
Use ROW_NUMBER() for pagination, deduplication (filtering for rn = 1), and when you need exactly N distinct rows per partition. Use DENSE_RANK() for leaderboard rankings (e.g. sports or sales tiers) and finding the top N distinct benchmark values (such as the 3 highest salaries).
Why does ROW_NUMBER() require a tiebreaker in ORDER BY?
If two rows have identical values in ORDER BY without a secondary unique column (like id DESC), the database engine assigns row numbers non-deterministically. Running the query twice could produce different winners for rn = 1.
Can I use ROW_NUMBER() or RANK() directly in a WHERE clause?
No. Window functions execute during the SELECT phase of query processing, which runs after the WHERE phase. To filter by a ranking result (such as WHERE rn = 1), you must wrap the ranking query in a Common Table Expression (CTE) or subquery.
What is the difference between PARTITION BY and GROUP BY?
GROUP BY collapses all rows in a group into a single aggregated summary row, discarding individual record details. PARTITION BY defines the subset of rows across which a window function calculates while keeping every single raw row intact in the final output.
Which ranking function should I use to find the second highest salary?
Use DENSE_RANK() OVER (ORDER BY salary DESC). DENSE_RANK guarantees that if multiple employees tie for the top salary at rank 1, the second highest distinct salary tier will consistently be evaluated at rank 2.

Written by
Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.
Related Articles
SQL RANK Function: RANK vs DENSE_RANK vs ROW_NUMBER Guide
Master the SQL RANK function with side-by-side comparisons of RANK, DENSE_RANK, and ROW_NUMBER, PARTITION BY logic, and Top-N group queries.
SQL Window Functions: Complete Guide with Examples (2026)
Master SQL window functions from complete basics. Understand why GROUP BY collapses rows, how OVER() preserves row details, and how to use ROW_NUMBER, RANK, LAG, LEAD, and running totals with real sample tables.
SQL CTE (WITH Clause): Syntax, Chaining, Recursive CTEs & Real Examples (2026)
Master SQL CTEs (Common Table Expressions) using the WITH clause. Learn exact syntax, execution lifecycle, chaining CTEs, recursive CTEs for org hierarchies, and CTEs vs subqueries vs temp tables.