Loading learning content...
We've established that RANK() recognizes ties but creates gaps afterward—if two people tie for 1st, the next person is 3rd (not 2nd). While mathematically precise, this can be inconvenient when you need consecutive rank values.
Consider assigning priority levels from 1 to 5 based on some ordering. If ties exist in priority 2, RANK() might jump to priority 4, leaving priority 3 unused. For scenarios requiring compact, sequential numbering, this creates problems.
DENSE_RANK() solves this: it recognizes ties (like RANK) but produces consecutive values (like ROW_NUMBER). Tied rows receive the same rank, but the next distinct value receives the immediately subsequent rank—no gaps.
By the end of this page, you will deeply understand DENSE_RANK()'s gap-free semantics, know exactly when to choose it over RANK() or ROW_NUMBER(), and apply it effectively to real-world scenarios requiring consecutive ranking with tie recognition.
DENSE_RANK() is a window function that assigns a rank to each row within a partition. Like RANK(), rows with identical ORDER BY values receive the same rank. Unlike RANK(), the next distinct value receives the immediately next integer—producing dense (gap-free) rank sequences.
Formal Definition:
DENSE_RANK() assigns to each row a number equal to 1 plus the count of distinct ORDER BY values that strictly precede it. Rows with identical ORDER BY values receive identical ranks. The sequence is consecutive: 1, 2, 3, ..., with no gaps regardless of ties.
The Dense Formula:
Dense Rank of current row = 1 + (count of distinct ORDER BY values that are strictly greater/less)
This produces consecutive integers because we count distinct values, not rows.
1234567891011121314151617
-- Basic SyntaxDENSE_RANK() OVER ( [PARTITION BY partition_expression, ...] ORDER BY sort_expression [ASC | DESC], ...) -- Minimal formDENSE_RANK() OVER (ORDER BY column_name) -- Full form with partitioningDENSE_RANK() OVER ( PARTITION BY category_column ORDER BY score DESC) -- Syntax is identical to RANK() and ROW_NUMBER()-- The semantic difference is in tie handling and gap behaviorUnderstanding DENSE_RANK() is clearest when comparing all three ranking functions simultaneously. Let's see their different behaviors with identical data.
Test Case: Sales Competition with Ties
123456789101112131415161718192021222324252627
-- Sales data with multiple tiesCREATE TABLE quarterly_sales ( salesperson_id INT PRIMARY KEY, salesperson_name VARCHAR(100), region VARCHAR(50), total_sales DECIMAL(12,2)); INSERT INTO quarterly_sales VALUES(1, 'Alice', 'North', 250000),(2, 'Bob', 'North', 220000),(3, 'Carol', 'North', 250000), -- Ties with Alice(4, 'David', 'North', 180000),(5, 'Emma', 'North', 220000), -- Ties with Bob(6, 'Frank', 'North', 220000), -- Three-way tie!(7, 'Grace', 'North', 150000),(8, 'Henry', 'North', 150000); -- Ties with Grace -- Compare all three ranking functionsSELECT salesperson_name, total_sales, ROW_NUMBER() OVER (ORDER BY total_sales DESC) AS row_num, RANK() OVER (ORDER BY total_sales DESC) AS rank_val, DENSE_RANK() OVER (ORDER BY total_sales DESC) AS dense_rank_valFROM quarterly_salesORDER BY total_sales DESC, salesperson_name;| salesperson_name | total_sales | row_num | rank_val | dense_rank_val |
|---|---|---|---|---|
| Alice | 250000 | 1 | 1 | 1 |
| Carol | 250000 | 2 | 1 | 1 |
| Bob | 220000 | 3 | 3 | 2 |
| Emma | 220000 | 4 | 3 | 2 |
| Frank | 220000 | 5 | 3 | 2 |
| David | 180000 | 6 | 6 | 3 |
| Grace | 150000 | 7 | 7 | 4 |
| Henry | 150000 | 8 | 7 | 4 |
Analysis by Sales Amount:
| Sales | People | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| $250K | Alice, Carol | 1, 2 | 1, 1 | 1, 1 |
| $220K | Bob, Emma, Frank | 3, 4, 5 | 3, 3, 3 | 2, 2, 2 |
| $180K | David | 6 | 6 | 3 |
| $150K | Grace, Henry | 7, 8 | 7, 7 | 4, 4 |
Key Observations:
The "Dense" Property:
DENSE_RANK's maximum value (4) equals the count of distinct sales amounts. Every integer from 1 to 4 appears at least once. This density is why it's called "dense rank."
ROW_NUMBER: Counts rows — Always unique, always sequential. RANK: Counts rows ahead — Ties same, then jumps. DENSE_RANK: Counts distinct values ahead — Ties same, no gaps.
Choosing between ranking functions requires understanding your specific requirements. DENSE_RANK() is the right choice in specific scenarios.
Decision Framework:
| Requirement | Use | Rationale |
|---|---|---|
| Need exactly N rows | ROW_NUMBER() | Guarantees unique numbers; exactly N when filtering <= N |
| Need ordinal position accuracy | RANK() | Gap after tie preserves 'N people ahead of me' meaning |
| Need consecutive rank numbers | DENSE_RANK() | No gaps means all ranks 1 to max are present |
| Need unique identifiers | ROW_NUMBER() | Only function guaranteeing one number per row |
| Need tie recognition | RANK() or DENSE_RANK() | Both give same rank to ties |
| Need to map to N buckets/tiers | DENSE_RANK() | Consecutive integers map cleanly to array indices or tiers |
Ideal DENSE_RANK() Scenarios:
12345678910111213141516171819202122232425
-- Assign products to exactly 5 price tiers-- DENSE_RANK ensures all tiers 1-5 have productsWITH price_ranked AS ( SELECT product_id, product_name, unit_price, DENSE_RANK() OVER (ORDER BY unit_price DESC) AS price_tier FROM products),max_tier AS ( SELECT MAX(price_tier) AS max_t FROM price_ranked)SELECT pr.product_name, pr.unit_price, pr.price_tier AS original_tier, -- Normalize to exactly 5 tiers (1-5) CEIL(pr.price_tier * 5.0 / mt.max_t) AS normalized_tierFROM price_ranked prCROSS JOIN max_tier mtORDER BY unit_price DESC; -- DENSE_RANK's consecutive values make tier normalization cleaner-- If RANK was used, gaps would complicate the mappingWhen filtering DENSE_RANK() <= 3, you get all rows with dense rank 1, 2, or 3—which may be many rows if ties exist. Unlike RANK(), you're guaranteed that ranks 1, 2, and 3 all exist (assuming at least 3 distinct values). This predictability is useful for reporting.
DENSE_RANK() has a clean mathematical definition based on counting distinct values rather than rows.
Formal Definition:
For a row r with ORDER BY value v within partition P:
DENSE_RANK(r) = 1 + |{ v' ∈ distinct_values(P) : v' > v }|
In words: the dense rank equals 1 plus the count of distinct ORDER BY values in the partition that are strictly greater than (for descending) the current row's value.
Mathematical Properties:
12345678910111213141516171819202122232425262728293031323334
-- Verify the density property: all ranks from 1 to max are presentWITH ranked AS ( SELECT DENSE_RANK() OVER (ORDER BY total_sales DESC) AS dr FROM quarterly_sales),rank_stats AS ( SELECT MIN(dr) AS min_rank, MAX(dr) AS max_rank, COUNT(DISTINCT dr) AS distinct_ranks FROM ranked)SELECT min_rank, max_rank, distinct_ranks, CASE WHEN distinct_ranks = max_rank - min_rank + 1 THEN 'Density verified: no gaps' ELSE 'Gap found (should never happen)' END AS verificationFROM rank_stats; -- Compare distinct ranks vs distinct ORDER BY valuesSELECT COUNT(DISTINCT total_sales) AS distinct_sales_values, MAX(DENSE_RANK() OVER (ORDER BY total_sales DESC)) AS max_dense_rank, CASE WHEN COUNT(DISTINCT total_sales) = MAX(DENSE_RANK() OVER (ORDER BY total_sales DESC)) THEN 'Max rank equals distinct value count' ELSE 'Mismatch (should never happen)' END AS value_count_verificationFROM quarterly_sales;Relationship Between Functions:
| Property | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| Maximum value | N (row count) | N (row count) | D (distinct value count) |
| Minimum value | 1 | 1 | 1 |
| Uniqueness | All unique | Ties share | Ties share |
| Gaps | Never | After ties | Never |
| Sum of ranks | N(N+1)/2 | ≤ N(N+1)/2 | ≤ D(D+1)/2 + ties |
The Compression Effect:
DENSE_RANK can be viewed as compressing RANK by removing gaps. If you imagine sliding all RANK values down to close the gaps while preserving tie equality, you get DENSE_RANK.
You can derive RANK from DENSE_RANK using: SUM(COUNT(*)) OVER (ORDER BY dense_rank) - COUNT(*) + 1. However, it's cleaner to just use the appropriate function directly. The optimization is handled by the database engine.
DENSE_RANK() excels in scenarios requiring consecutive rank numbers. Let's explore practical applications.
Application 1: Finding the Nth Highest Value
A classic interview question: find the employee with the Nth highest salary. DENSE_RANK makes this elegant:
123456789101112131415161718192021
-- Find the 3rd highest salary (handles ties correctly)-- If multiple people share 3rd highest, return all of themWITH salary_ranked AS ( SELECT employee_id, employee_name, salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS salary_rank FROM employees)SELECT employee_name, salaryFROM salary_rankedWHERE salary_rank = 3; -- Why DENSE_RANK is correct here:-- If salaries are: 100K, 90K, 90K, 80K, 70K-- RANK gives: 1, 2, 2, 4, 5 → 3rd highest doesn't exist!-- DENSE_RANK gives: 1, 2, 2, 3, 4 → 3rd highest is 80K-- The question asks for "3rd highest VALUE", not "3rd position"Application 2: Consecutive Tier Assignment
Assigning data to numbered tiers where each tier must be represented:
12345678910111213141516171819202122232425262728293031
-- Assign customers to loyalty tiers based on spending-- Tier 1 = highest spenders, Tier 2 = next level, etc.WITH spending_ranked AS ( SELECT customer_id, customer_name, total_spending, DENSE_RANK() OVER (ORDER BY total_spending DESC) AS spending_rank FROM customer_spending),tier_mapping AS ( SELECT customer_id, customer_name, total_spending, spending_rank, CASE WHEN spending_rank = 1 THEN 'Platinum Elite' WHEN spending_rank = 2 THEN 'Platinum' WHEN spending_rank <= 5 THEN 'Gold' WHEN spending_rank <= 10 THEN 'Silver' ELSE 'Bronze' END AS loyalty_tier FROM spending_ranked)SELECT * FROM tier_mapping ORDER BY spending_rank; -- DENSE_RANK ensures:-- 1. Customers who spend the same amount get the same tier-- 2. Tier thresholds work predictably (rank <= 5 means top 5 values)-- 3. All tier ranks are used - no gaps in the tier logicApplication 3: Finding Top N Distinct Values Within Groups
Selecting the top N distinct values per group:
1234567891011121314151617181920212223
-- Find top 3 salary amounts in each department-- (Not top 3 employees - top 3 distinct salary levels)WITH salary_levels AS ( SELECT department, salary, DENSE_RANK() OVER ( PARTITION BY department ORDER BY salary DESC ) AS salary_level FROM employees)SELECT DISTINCT department, salary, salary_levelFROM salary_levelsWHERE salary_level <= 3ORDER BY department, salary_level; -- This returns up to 3 rows per department (the 3 highest salary amounts)-- NOT 3 employees per department-- Multiple employees can share each salary levelDENSE_RANK ranks by distinct values, making it perfect for 'Nth highest value' queries. RANK ranks by position, making it right for 'Nth place' queries. The difference is subtle but critical for correct results.
Application 4: Gap-Free Sequence for Display
Generating display-friendly rankings for user interfaces:
1234567891011121314151617181920
-- Leaderboard with user-friendly rankings-- Users expect 1st, 2nd, 3rd not 1st, 1st, 3rdSELECT player_name, score, DENSE_RANK() OVER (ORDER BY score DESC) AS display_rank, CASE DENSE_RANK() OVER (ORDER BY score DESC) WHEN 1 THEN '🥇 1st Place' WHEN 2 THEN '🥈 2nd Place' WHEN 3 THEN '🥉 3rd Place' ELSE CONCAT('#', DENSE_RANK() OVER (ORDER BY score DESC)) END AS formatted_rankFROM game_scoresWHERE game_id = 'current_season'ORDER BY display_rankLIMIT 10; -- For display purposes, DENSE_RANK is often preferred:-- Users see: 1st, 1st, 2nd, 3rd (all places represented)-- Instead of: 1st, 1st, 3rd, 4th (skipping 2nd place is confusing)Like all ranking functions, DENSE_RANK() applies independently within each partition. The density property holds within each partition—not globally.
1234567891011121314151617181920
-- Rank products by sales within each category-- Using all three functions for comparisonSELECT category, product_name, monthly_sales, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY monthly_sales DESC ) AS rn, RANK() OVER ( PARTITION BY category ORDER BY monthly_sales DESC ) AS rnk, DENSE_RANK() OVER ( PARTITION BY category ORDER BY monthly_sales DESC ) AS drnkFROM productsORDER BY category, drnk;| category | product_name | monthly_sales | rn | rnk | drnk |
|---|---|---|---|---|---|
| Electronics | Phone Pro | 50000 | 1 | 1 | 1 |
| Electronics | Tablet X | 50000 | 2 | 1 | 1 |
| Electronics | Laptop Y | 40000 | 3 | 3 | 2 |
| Electronics | Mouse Z | 30000 | 4 | 4 | 3 |
| Furniture | Desk A | 25000 | 1 | 1 | 1 |
| Furniture | Chair B | 20000 | 2 | 2 | 2 |
| Furniture | Shelf C | 20000 | 3 | 2 | 2 |
| Furniture | Lamp D | 15000 | 4 | 4 | 3 |
Key Observations:
Electronics: Phone Pro and Tablet X tie at 50K sales.
Furniture: Chair B and Shelf C tie at 20K sales.
Max values per partition:
The DENSE_RANK maximum reflects distinct sales values, not row counts.
MAX(DENSE_RANK()) OVER (PARTITION BY ...) gives you the count of distinct ORDER BY values in each partition—useful for analytical queries without a separate COUNT(DISTINCT...).
DENSE_RANK enables sophisticated analytical patterns due to its gap-free nature.
Pattern 1: Selecting All Rows Sharing Top N Values
Get all rows that share the top N distinct values:
12345678910111213141516171819202122232425
-- Select all orders with top 5 order amounts-- May return more than 5 orders if amounts repeatWITH amount_ranked AS ( SELECT order_id, customer_id, order_amount, order_date, DENSE_RANK() OVER (ORDER BY order_amount DESC) AS amount_rank FROM orders)SELECT order_id, customer_id, order_amount, order_date, amount_rankFROM amount_rankedWHERE amount_rank <= 5ORDER BY amount_rank, order_date; -- This guarantees:-- 1. All 5 highest order amounts are included-- 2. ALL orders with those amounts are included-- 3. No gaps in the rank sequence (1, 2, 3, 4, 5 all present)Pattern 2: Grouping by Rank Bucket
Using DENSE_RANK as a grouping key for aggregation:
12345678910111213141516171819202122232425
-- Analyze performance by salary level-- Group employees by salary rank (dense) and aggregateWITH salary_ranked AS ( SELECT employee_id, department, salary, performance_score, DENSE_RANK() OVER (ORDER BY salary DESC) AS salary_level FROM employees)SELECT salary_level, COUNT(*) AS employee_count, MIN(salary) AS level_salary, -- All same in each level AVG(performance_score) AS avg_performance, MIN(performance_score) AS min_performance, MAX(performance_score) AS max_performanceFROM salary_rankedGROUP BY salary_levelORDER BY salary_level; -- DENSE_RANK makes salary_level a clean grouping key:-- Level 1 = highest earners, Level 2 = next, etc.-- No skipped levels (all 1 to max present)Pattern 3: Calculating Ranks Within Ranks
Nested ranking for multi-level hierarchies:
12345678910111213141516171819202122232425262728293031323334
-- Two-level ranking: Rank regions, then rank salespeople within regionsWITH region_totals AS ( SELECT region, SUM(total_sales) AS region_sales FROM salespeople GROUP BY region),region_ranked AS ( SELECT s.*, rt.region_sales, DENSE_RANK() OVER (ORDER BY rt.region_sales DESC) AS region_rank, DENSE_RANK() OVER ( PARTITION BY s.region ORDER BY s.total_sales DESC ) AS rank_in_region FROM salespeople s JOIN region_totals rt ON s.region = rt.region)SELECT region, region_sales, region_rank, salesperson_name, total_sales, rank_in_region, CONCAT(region_rank, '.', rank_in_region) AS composite_rankFROM region_rankedORDER BY region_rank, rank_in_region; -- Composite rank like "1.3" means:-- Region ranked 1st overall, salesperson ranked 3rd within that region-- DENSE_RANK ensures both components are gap-freeSince DENSE_RANK produces consecutive integers from 1, it maps perfectly to array indices (with offset -1). This is useful when the rank value will be used in application code to index into arrays or lookup tables.
While DENSE_RANK() is intuitive, several pitfalls can lead to incorrect results or confused interpretations.
12345678910111213141516171819202122232425262728293031
-- Pitfall 1: Confusing rank types-- Q: "Who finished 3rd in the race?"-- Wrong: DENSE_RANK (gives 3rd fastest time, not 3rd position)-- Right: RANK (gives actual 3rd place) -- Example data: Times 10s, 10s, 12s, 13s-- DENSE_RANK: 1, 1, 2, 3 → "3rd" is 13s (wrong for position)-- RANK: 1, 1, 3, 4 → "3rd" is 12s (correct for position) -- Pitfall 2: Expecting fixed row counts-- Want exactly 3 products per categorySELECT * FROM ( SELECT category, product_name, DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS dr FROM products) rankedWHERE dr <= 3;-- May return 5 products in a category if 3 tie for 2nd place! -- Solution: Use ROW_NUMBER for exact countsSELECT * FROM ( SELECT category, product_name, ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn FROM products) rankedWHERE rn <= 3;-- Guarantees exactly 3 per categoryThis is the most common source of confusion. 'The person in 3rd place' (RANK) is different from 'the person with the 3rd highest value' (DENSE_RANK) when ties exist. Always clarify which interpretation the business requirement intends.
DENSE_RANK() completes the core ranking function trio by providing tie-aware rankings without gaps. Let's consolidate the essential knowledge:
| Function | Ties | Gaps | Max Value | Best Use Case |
|---|---|---|---|---|
| ROW_NUMBER() | Ignores (unique) | Never | Row count | Pagination, deduplication |
| RANK() | Recognizes | Yes, after ties | Row count | Competition rankings |
| DENSE_RANK() | Recognizes | Never | Distinct value count | Nth value queries, tiers |
What's Next:
With ROW_NUMBER, RANK, and DENSE_RANK understood, we'll explore NTILE() in the next page. NTILE divides your data into a specified number of equal groups (tiles/buckets), assigning each row a tile number. This is invaluable for percentile analysis, quartile calculations, and distributing data into balanced buckets.
You now possess comprehensive knowledge of DENSE_RANK()—from its mathematical foundation through practical applications. With all three core ranking functions mastered, you can choose the right tool for any ranking scenario. NTILE adds the final dimension: equal distribution into buckets.