Ranking in DAX looks simple until your measures start returning 1s everywhere or skipping ranks inexplicably. This lesson builds a complete, production-ready understanding of RANKX and TOPN — including category-relative rankings, dynamic top-N leaderboards, and how to diagnose ranking failures across changing filter contexts.

You've built a sales dashboard that looks great — until your manager asks, "Can you show me each product's rank within its category, but only for the top 10 customers?" You write a RANKX measure, add it to a matrix, and immediately start seeing 1s everywhere. Or worse, ranks that jump from 3 to 7 with nothing in between, and you have no idea why. Ranking in DAX is one of those areas where the concept seems simple but the execution reveals the full complexity of the filter context engine.
This lesson is about understanding ranking patterns at a production level. We're not going to scratch the surface with a basic RANKX formula and call it a day. We're going to dig into why RANKX behaves the way it does across different visual contexts, how dense and sparse rankings differ and when each is appropriate, how TOPN works as a table function (not just a visual trick), and how to combine these tools to answer genuinely complex business questions.
By the end of this lesson, you'll have a toolkit of ranking patterns you can adapt to real reporting scenarios — from leaderboards to dynamic top-N filtering to category-relative rankings — along with the debugging mindset to fix them when they inevitably break.
What you'll learn:
You should be comfortable with:
If any of those feel shaky, spend time with the core DAX context lessons before continuing. This lesson builds on that foundation without re-explaining it.
Before writing any ranking measure, you need a mental model of what RANKX does mechanically. The signature is:
RANKX(<table>, <expression>, [<value>], [<order>], [<ties>])
Here's the key insight most people miss: RANKX iterates twice. First, it evaluates the <expression> for every row in <table>, building an internal list of values. Then it evaluates <value> — which defaults to the same <expression> evaluated in the current filter context — and finds where that value falls in the list.
Let's make this concrete. Suppose you have a simple Sales table and you want to rank products by total sales:
Product Sales Rank =
RANKX(
ALL( 'Product'[ProductName] ),
[Total Sales],
,
DESC,
DENSE
)
What happens step by step:
ALL( 'Product'[ProductName] ), which removes any filter on ProductName and gives you all products. For each product in that table, it evaluates [Total Sales].[Total Sales] in the current filter context — which in a matrix or table visual will be a specific product — and finds where that value sits in the sorted list.This is why the <table> argument is so critical. It defines the population you're ranking within. Get that wrong, and every cell returns rank 1 because the current product is the only thing in the pool.
Warning: A very common mistake is passing
VALUES( 'Product'[ProductName] )as the table. This gives you only the products visible in the current filter context, so every row competes only against itself and always gets rank 1. UseALL()orALLSELECTED()depending on your intent.
This is one of the most consequential decisions you'll make in a ranking measure. The choice between ALL and ALLSELECTED determines whether your ranking is global or slicer-aware.
-- Global rank: ignores all slicers, ranks against all products ever
Product Global Rank =
RANKX(
ALL( 'Product'[ProductName] ),
[Total Sales],
,
DESC,
DENSE
)
-- Slicer-aware rank: ranks within whatever the user has filtered to
Product Filtered Rank =
RANKX(
ALLSELECTED( 'Product'[ProductName] ),
[Total Sales],
,
DESC,
DENSE
)
Consider a scenario: you have 500 products. A user filters to a specific brand with a slicer, leaving 40 products visible. With ALL, product rank 1 might be a product from a different brand entirely — the top performer globally. With ALLSELECTED, rank 1 is the best performer among the 40 visible products.
Neither is wrong. They answer different business questions. "Where does this product rank globally?" versus "How does it rank among the products I care about right now?" Know which question your stakeholder is asking before you write the formula.
Tip:
ALLSELECTEDpreserves the filter context from slicers that exist outside the current visual but ignores filters applied within the visual (like row/column headers). This is exactly the behavior you want for leaderboard-style rankings that respond to user selections.
The fifth parameter of RANKX controls tie-breaking behavior, and it's called <ties>. Your options are DENSE and SKIP (sparse). The default is SKIP.
Imagine three products with these sales totals: Product A: $100K, Product B: $100K, Product C: $75K.
With SKIP (sparse):
With DENSE:
The naming is intuitive once you think about it: DENSE keeps ranks tightly packed (no gaps), while SKIP skips ranks that are "used up" by ties.
When to use each:
Use DENSE when:
Use SKIP when:
Here's a pattern that makes the difference obvious in a real report. Suppose you're building a sales leaderboard and two salespeople have identical performance. With SKIP, users see a jump from rank 2 to rank 4, which generates helpdesk tickets. Use DENSE:
Salesperson Rank (Dense) =
RANKX(
ALLSELECTED( 'Salesperson'[Name] ),
[Total Sales],
,
DESC,
DENSE
)
Now we get into territory where most practitioners struggle. The requirement sounds simple: "Rank each product within its category." In a matrix with Category on rows and Product on columns (or nested rows), the filter context already includes the category. But the moment you put this in a flat table or try to use it across visuals, things break.
Let's build this correctly. Our model has a Sales fact table related to a Product dimension that includes Category.
Product Rank Within Category =
RANKX(
CALCULATETABLE(
VALUES( 'Product'[ProductName] ),
ALLSELECTED( 'Product'[ProductName] ),
VALUES( 'Product'[Category] )
),
[Total Sales],
,
DESC,
DENSE
)
Let's dissect this. CALCULATETABLE lets us build a table while controlling the filter context:
VALUES( 'Product'[ProductName] ) — the base table of product namesALLSELECTED( 'Product'[ProductName] ) — removes the current product filter (so we see all products, not just the one in the current row)VALUES( 'Product'[Category] ) — keeps the current category filterThe result: for each row in the visual, we build a table of all products in the current category, regardless of which specific product is currently being evaluated, then rank within that pool.
Tip:
CALCULATETABLEis your primary tool for building context-aware tables for RANKX to iterate over. Think of it as "give me a table, but with this specific filter context applied."
This pattern also works when Category comes from a slicer rather than a visual filter:
Product Rank Within Selected Categories =
RANKX(
CALCULATETABLE(
VALUES( 'Product'[ProductName] ),
ALLSELECTED( 'Product'[ProductName] ),
ALLSELECTED( 'Product'[Category] )
),
[Total Sales],
,
DESC,
DENSE
)
Here, ALLSELECTED( 'Product'[Category] ) means "all categories the user has selected in slicers," so if someone picks Electronics and Appliances from a category slicer, ranks are computed across products in both those categories.
Most Power BI users encounter TOPN through the built-in visual filter ("Top N" filter type). But TOPN is a DAX table function, and understanding it at that level opens up much more powerful patterns.
The signature:
TOPN(<n>, <table>, <orderBy_expression>, [<order>], ...)
TOPN returns a table containing the top N rows from <table> sorted by <orderBy_expression>. You can pass this table to CALCULATE, use it as a filter argument, or iterate over it with iterators like SUMX.
Top 10 Customer Sales =
CALCULATE(
[Total Sales],
TOPN(
10,
ALL( 'Customer'[CustomerID] ),
[Total Sales],
DESC
)
)
This measure calculates total sales, but only for the 10 customers with the highest total sales globally. Note we use ALL( 'Customer'[CustomerID] ) to ensure we're looking at the top 10 globally, not the top 10 in whatever the current filter context happens to be.
You can make this dynamic with a parameter:
Top N Customer Sales =
VAR TopN_Count = SELECTEDVALUE( 'TopN Parameter'[Value], 10 )
RETURN
CALCULATE(
[Total Sales],
TOPN(
TopN_Count,
ALLSELECTED( 'Customer'[CustomerID] ),
[Total Sales],
DESC
)
)
Here, 'TopN Parameter' is a disconnected table (often created with the "New Parameter" feature in Power BI) containing values like 5, 10, 20, 50. The user picks a value from a slicer, and the measure dynamically adjusts.
Warning: TOPN does not guarantee a deterministic result when there are ties at the boundary. If you ask for the top 10 customers and positions 9, 10, and 11 all have identical sales, TOPN will arbitrarily include two of the three tied customers. This can lead to inconsistent results across refreshes or across visuals. We'll address this in the troubleshooting section.
A powerful pattern for conditional formatting or displaying a "Top Performer" flag:
Is Top 10 Customer =
VAR TopCustomers =
TOPN(
10,
ALL( 'Customer'[CustomerID] ),
[Total Sales],
DESC
)
VAR CurrentCustomer =
VALUES( 'Customer'[CustomerID] )
RETURN
IF(
COUNTROWS( INTERSECT( TopCustomers, CurrentCustomer ) ) > 0,
"Top 10",
"Other"
)
INTERSECT returns rows that appear in both tables. If the current customer (a single-row table when evaluated in a row context of a matrix) appears in the top 10 table, we return "Top 10." This works beautifully as a conditional formatting measure.
Let's build something complete: a sales leaderboard that shows each salesperson's rank among the top N performers, suppresses ranks for non-top-performers, and respects slicer selections on region and time period.
Start with your measures in isolation, then combine them:
-- Step 1: Total Sales (your base measure)
Total Sales =
SUMX(
Sales,
Sales[Quantity] * Sales[UnitPrice]
)
-- Step 2: The top N table (reusable variable pattern)
-- We'll use N = 10 for now, parameterize later
Salesperson Rank (Top 10 Only) =
VAR N = 10
VAR TopSalespeople =
TOPN(
N,
ALLSELECTED( 'Salesperson'[SalespersonID] ),
[Total Sales],
DESC
)
VAR CurrentSalesperson =
VALUES( 'Salesperson'[SalespersonID] )
VAR IsInTopN =
COUNTROWS( INTERSECT( TopSalespeople, CurrentSalesperson ) ) > 0
VAR Rank =
RANKX(
TopSalespeople,
[Total Sales],
,
DESC,
DENSE
)
RETURN
IF( IsInTopN, Rank, BLANK() )
This measure returns a rank only for salespeople in the top 10. Everyone else gets BLANK(), which means they won't clutter a visual or can be filtered out. The RANKX here uses TopSalespeople as its table — so the rank is computed within the top 10 pool, not against all salespeople. This means ranks 1-10 are always assigned to exactly the top 10 performers.
To parameterize N:
Salesperson Rank (Dynamic Top N) =
VAR N = SELECTEDVALUE( 'TopN Parameter'[Value], 10 )
VAR TopSalespeople =
TOPN(
N,
ALLSELECTED( 'Salesperson'[SalespersonID] ),
[Total Sales],
DESC
)
VAR CurrentSalesperson =
VALUES( 'Salesperson'[SalespersonID] )
VAR IsInTopN =
COUNTROWS( INTERSECT( TopSalespeople, CurrentSalesperson ) ) > 0
VAR Rank =
RANKX(
TopSalespeople,
[Total Sales],
,
DESC,
DENSE
)
RETURN
IF( IsInTopN, Rank, BLANK() )
Sometimes rank by a single metric isn't enough. You might need to rank by revenue first, then by units sold as a tiebreaker. RANKX doesn't natively support multi-key sorting, but you can engineer it:
Product Composite Score =
VAR RevenueScore =
RANKX(
ALLSELECTED( 'Product'[ProductName] ),
[Total Sales],
,
DESC,
DENSE
)
VAR UnitScore =
RANKX(
ALLSELECTED( 'Product'[ProductName] ),
[Units Sold],
,
DESC,
DENSE
)
RETURN
RevenueScore + ( UnitScore * 0.001 )
By adding a fractional unit rank to the revenue rank, you create a composite score where revenue is the primary sort key and units sold breaks ties. Products with the same revenue but higher units get a slightly lower composite score, making them rank higher.
You'd typically not show this composite score directly — instead you'd use it as the expression in another RANKX:
Product Final Rank =
RANKX(
ALLSELECTED( 'Product'[ProductName] ),
[Product Composite Score],
,
ASC, -- Lower composite score = better rank
DENSE
)
Tip: The 0.001 multiplier works as long as you have fewer than 1,000 products. If your product count is larger, use a smaller multiplier like 0.0001 or normalize the scores to a fixed range first.
Ranking measures can be expensive. Every RANKX call iterates over a table and performs a comparison — potentially thousands of calculations per cell in a matrix visual. Here's how to keep things fast:
1. Minimize the iteration table size. Use the smallest table that answers the question. If you're ranking 50 products, don't pass ALL('Product') with 10,000 rows just because it's available.
2. Avoid RANKX on calculated columns when you can use measures. Calculated columns compute at refresh time and are static, but they don't respond to filter context. For dynamic ranking (which is almost always what you want), use measures.
3. Use variables to compute TOPN once. In our leaderboard pattern, we computed TopSalespeople once as a VAR and reused it for both the IsInTopN check and the RANKX call. Without variables, you'd compute TOPN twice.
4. Watch out for ALLSELECTED in complex models. ALLSELECTED is powerful but can be slow in models with many relationships and large fact tables. Profile with Performance Analyzer in Power BI Desktop if ranks feel sluggish.
5. Consider whether you need ranking at all. Sometimes a percent-of-total measure answers the business question just as well as a rank, and it's much cheaper to compute. Discuss with your stakeholder before building complex ranking logic.
For this exercise, imagine you're working with a retail dataset containing:
Sales fact table with OrderDate, CustomerID, ProductID, SalespersonID, Quantity, UnitPriceSalesperson dimension with SalespersonID, Name, RegionDate tableTopN Parameter table with values 5, 10, 15, 20, 25Part 1: Build the base measures
Create these measures in your model:
Total Sales =
SUMX( Sales, Sales[Quantity] * Sales[UnitPrice] )
Total Orders =
DISTINCTCOUNT( Sales[OrderID] )
Avg Order Value =
DIVIDE( [Total Sales], [Total Orders] )
Part 2: Build the regional rank
Salesperson Rank Within Region =
RANKX(
CALCULATETABLE(
VALUES( 'Salesperson'[SalespersonID] ),
ALLSELECTED( 'Salesperson'[SalespersonID] ),
VALUES( 'Salesperson'[Region] )
),
[Total Sales],
,
DESC,
DENSE
)
Add this to a matrix with Region on rows and Salesperson Name on rows nested under Region. Each salesperson should be ranked 1, 2, 3... within their region, independent of other regions.
Part 3: Build the dynamic top-N leaderboard
Is Top N Salesperson =
VAR N = SELECTEDVALUE( 'TopN Parameter'[Value], 10 )
VAR TopSalespeople =
TOPN(
N,
ALLSELECTED( 'Salesperson'[SalespersonID] ),
[Total Sales],
DESC
)
VAR CurrentSalesperson =
VALUES( 'Salesperson'[SalespersonID] )
RETURN
COUNTROWS( INTERSECT( TopSalespeople, CurrentSalesperson ) ) > 0
Overall Rank (Top N Only) =
VAR N = SELECTEDVALUE( 'TopN Parameter'[Value], 10 )
VAR TopSalespeople =
TOPN(
N,
ALLSELECTED( 'Salesperson'[SalespersonID] ),
[Total Sales],
DESC
)
VAR CurrentSalesperson =
VALUES( 'Salesperson'[SalespersonID] )
VAR IsInTopN =
COUNTROWS( INTERSECT( TopSalespeople, CurrentSalesperson ) ) > 0
RETURN
IF(
IsInTopN,
RANKX( TopSalespeople, [Total Sales], , DESC, DENSE ),
BLANK()
)
Part 4: Wire it up
Challenge: Modify the Regional Rank measure to also show a flag when a salesperson's regional rank is 1 (the regional champion). Use conditional formatting rules in Power BI to highlight these cells.
Cause: The table argument to RANKX is filtering to only the current item. This usually happens when you use VALUES() instead of ALL() or ALLSELECTED() as the table.
Diagnosis: Put COUNTROWS() using the same table argument into a separate measure and add it to your visual. If it always returns 1, your table is always single-row.
Fix: Replace VALUES( 'Product'[ProductName] ) with ALLSELECTED( 'Product'[ProductName] ) in the RANKX table argument.
Cause: You're using SKIP (the default) tie behavior and there are ties in the ranking expression. The ranks 2 and 3 are being "used" by the tie at rank 1.
Fix: Either use DENSE as the fifth argument, or investigate whether ties are expected. If the data shouldn't have ties, check whether your [Total Sales] measure is computing the same value for multiple items due to a model relationship issue.
Cause: You used ALL() instead of ALLSELECTED() in the table argument. ALL() ignores all external filter context, including slicers.
Fix: Replace ALL() with ALLSELECTED(). Verify by adding a region slicer and confirming ranks change.
Cause: Tied values at the Nth position. TOPN uses an arbitrary tiebreaker when values at the boundary are equal.
Fix: Add a secondary sort expression to TOPN:
VAR TopSalespeople =
TOPN(
N,
ALLSELECTED( 'Salesperson'[SalespersonID] ),
[Total Sales], DESC,
'Salesperson'[Name], ASC -- Alphabetical tiebreaker
)
This makes the result deterministic: among tied salespeople, alphabetical order decides who makes the cut.
Cause: In a matrix, the category filter is applied by the visual. In a flat table, all rows are evaluated in the same filter context and the category filter may not be present.
Fix: Your CALCULATETABLE expression needs VALUES( 'Product'[Category] ) to explicitly pull the current category into scope. If the visual doesn't have Category as a field, this will return all categories and ranks will be global, not category-relative. Ensure the visual includes a category field that creates the needed filter context.
Cause: RANKX iterates over the full table for every cell in the visual. With 10,000 products in 50 categories, a matrix could trigger hundreds of thousands of RANKX evaluations.
Fix options:
Cause: At the total row level, VALUES() returns all products, making the measure ambiguous. A rank for "all products" doesn't make sense.
Fix: This is usually the correct behavior — a total row rank is conceptually meaningless. But if you need something in the total row (like the rank of the subtotal value), handle it explicitly:
Product Rank with Total =
IF(
HASONEVALUE( 'Product'[ProductName] ),
RANKX(
ALLSELECTED( 'Product'[ProductName] ),
[Total Sales],
,
DESC,
DENSE
),
BLANK() -- Or some other value for totals
)
HASONEVALUE returns TRUE only when the filter context contains exactly one product, which is false at subtotal and total rows.
You now have a production-ready understanding of DAX ranking patterns. Let's recap the key mental models:
RANKX iterates twice: once to build the comparison pool from the <table> argument, and once to evaluate the current item's value in the current filter context. The table argument is everything — get it wrong and ranks collapse to 1.
ALL vs. ALLSELECTED is a business question: global rankings use ALL, slicer-aware rankings use ALLSELECTED. Know which your stakeholder needs before writing the formula.
Dense vs. Sparse has real UX implications: DENSE eliminates confusing gaps for end users; SKIP preserves ordinal accuracy for analytical use. Default is SKIP, but most dashboard scenarios benefit from DENSE.
TOPN is a table function: use it to build filtered calculation contexts, check membership, or drive dynamic N patterns with parameter slicers. It's not just a visual feature.
CALCULATETABLE is your scalpel for building context-aware populations: category-relative rankings, region-relative rankings, and other grouped ranking patterns all depend on building the right table with the right filter context modifications.
For next steps, consider exploring:
Ranking in DAX rewards careful thinking about what population you're ranking within and what filter context is active when the measure evaluates. Once you internalize those two axes, the formulas become predictable — and debugging them becomes systematic rather than guesswork.