Learn how to build a fully dynamic ABC classification system in DAX that ranks products or customers by revenue contribution, computes cumulative Pareto percentages, and assigns tier labels that update in real time as filters and thresholds change. This lesson covers the complete pattern from RANKX through cumulative logic to dynamic threshold parameters — including performance trade-offs for large catalogs.

You've got 50,000 SKUs in your product catalog. Your VP of Supply Chain wants to know which ones are driving 80% of revenue so she can prioritize safety stock decisions. Your Sales Director wants to know which customers represent the top tier so he can allocate account management resources. Your CFO wants both analyses sliced by region, by quarter, and by product category — dynamically, as filters change.
This is the ABC classification problem, and it shows up constantly in retail, manufacturing, logistics, and finance. The classic Pareto principle (80% of effects come from 20% of causes) gives you the conceptual framework, but actually implementing it in Power BI — where filters shift, contexts change, and the underlying ranked population changes with every slicer click — is a different challenge entirely. Done naively, ABC classifications break silently: the segment labels appear, but they're computed against the wrong population, or they refuse to update when filters change, or they collapse under a cross-filter from a dimension table.
By the end of this lesson, you'll know how to build a production-grade, fully dynamic ABC classification system in DAX that respects filter context, handles edge cases gracefully, and lets business users adjust the A/B/C thresholds at runtime using a parameter table. Specifically, you'll learn:
What you'll learn:
RANKX with ALL and ALLSELECTED to rank items within the correct populationYou should be comfortable with:
CALCULATE, ALL, ALLSELECTED, and filter context manipulation — see Master DAX CALCULATE and Filter Context in Power BI if you need a refresherRANKX and ranking patterns — DAX Ranking Patterns in Practice: RANKX, TOPN, and Dense vs. Sparse Rankings Across Dynamic Filter Contexts covers the mechanics thoroughlyVAR / RETURN) for readable measure construction — see DAX Variables in Practice: Using VAR and RETURN to Simplify Complex MeasuresBefore writing any DAX, let's establish the scenario so every measure makes intuitive sense.
We have a retail analytics model with these tables:
OrderDate, ProductKey, CustomerKey, Quantity, UnitPrice, RevenueProductKey, ProductName, Category, SubCategoryCustomerKey, CustomerName, Region, SegmentDate, Year, Quarter, MonthThe relationships are standard star schema — Sales filters Products, Customers, and Calendar. We're going to build ABC classification at the product level first, then show you how to adapt it for customers.
The goal: classify each product as A (top tier, capturing the first 70% of total revenue), B (middle tier, from 70% to 90%), or C (the long tail, from 90% to 100%). These thresholds will be adjustable.
Before jumping to the solution, you need to understand the fundamental tension in this problem. ABC classification requires two things simultaneously:
The problem is that "cumulative up to rank N" means you need to sum the revenue for all items ranked 1 through N — which requires aggregating over multiple rows while you're currently evaluating a single row. In a calculated column, you might reach for EARLIER to cross-reference the current row context against other rows. But we want a measure, not a calculated column, because only measures respond dynamically to slicer filters.
Key insight
A measure evaluates in filter context, not row context. When your visual has Products on rows, each product's measure evaluates with that product filtered in. To compute "what percentage of total revenue comes from products ranked above this one," you have to temporarily remove the product filter, iterate over all products, and accumulate revenue up to the current product's rank. This is the core pattern we're about to build.
This is fundamentally a dynamic segmentation problem, and it's also a great showcase for DAX virtual table patterns — because the cumulative logic essentially builds an in-memory ranked table and queries it.
Start with the simple stuff. These measures will become building blocks.
-- Base revenue measure
Revenue = SUM(Sales[Revenue])
-- Total revenue across ALL products (ignoring any product filter)
Revenue All Products =
CALCULATE(
[Revenue],
ALL(Products)
)
-- Total revenue across all products WITHIN the current filter context
-- (respects slicers on Category, Region, Date, etc., but ignores the product filter)
Revenue All Products Selected =
CALCULATE(
[Revenue],
ALLSELECTED(Products)
)
The difference between ALL(Products) and ALLSELECTED(Products) is crucial and easy to get wrong. ALL removes every filter on Products, including filters from other slicers that cross-filter the dimension. ALLSELECTED removes only the visual's own row/column filter on Products, but preserves filters coming from slicers and other cross-filters.
Warning
Which one you use determines what "100% of revenue" means. If your Category slicer is set to "Electronics," ALLSELECTED will compute cumulative percentage within Electronics, while ALL will compute it against the entire catalog. Neither is wrong — but they answer different business questions. We'll build for ALLSELECTED (the more common requirement) and explain when to switch.
Now let's rank each product by its revenue, highest to lowest, within the selected context.
Product Revenue Rank =
VAR CurrentRevenue = [Revenue]
RETURN
RANKX(
ALLSELECTED(Products),
[Revenue],
CurrentRevenue,
DESC,
Dense
)
A few things to unpack here:
ALLSELECTED(Products) is the table that RANKX iterates over. This gives us all products currently visible in the report's filter context — meaning the rank is relative to the filtered population, not the entire catalog.[Revenue] in a variable (CurrentRevenue) before entering RANKX. This is important: inside RANKX, the [Revenue] expression is evaluated once per row of the ALLSELECTED(Products) table. If we just wrote [Revenue] for the third argument (the value to compare), it would be re-evaluated in each row's context and might not give you what you expect. Storing it in VAR first captures the current product's revenue cleanly.Dense ranking means products with the same revenue share the same rank, and the next rank is sequential (1, 2, 2, 3 rather than 1, 2, 2, 4).Tip
Put this measure in a table visual with Product Name to verify it's working before building on top of it. Sort by rank and confirm the top revenue products get rank 1, 2, 3. If you apply a Category slicer, the ranks should reset to 1-based within the filtered category.
This is where most implementations go wrong. Let's build it carefully.
The cumulative revenue share for a product at rank N = (sum of revenue for all products ranked 1 through N) / (total revenue for all products in scope).
In DAX, "sum of revenue for products ranked ≤ N" translates to: iterate over all products in scope, keep only those whose rank is less than or equal to the current product's rank, and sum their revenue.
Cumulative Revenue % =
VAR CurrentRank = [Product Revenue Rank]
VAR TotalRevenue = [Revenue All Products Selected]
VAR CumulativeRevenue =
CALCULATE(
[Revenue],
FILTER(
ALLSELECTED(Products),
[Product Revenue Rank] <= CurrentRank
)
)
RETURN
DIVIDE(CumulativeRevenue, TotalRevenue)
Let's trace through what happens when this evaluates for, say, "Wireless Headphones" (which has rank 3):
CurrentRank = 3TotalRevenue = total revenue across all selected productsFILTER(ALLSELECTED(Products), [Product Revenue Rank] <= 3) iterates over every selected product and keeps only those ranked 1, 2, or 3. This temporarily removes the "Wireless Headphones" filter context and evaluates [Product Revenue Rank] for each product in the ALLSELECTED table.CALCULATE([Revenue], ...) then sums revenue over only those top-3 products.Warning
This pattern calls [Product Revenue Rank] inside a FILTER, which itself calls [Revenue] inside RANKX, which iterates ALLSELECTED(Products). You have nested iterators here. For large product catalogs (thousands of SKUs), this can be slow. We'll address performance in the troubleshooting section. For now, build correctness first.
With Cumulative Revenue % working, the classification is just threshold logic:
ABC Class (Fixed) =
VAR CumPct = [Cumulative Revenue %]
RETURN
SWITCH(
TRUE(),
ISBLANK(CumPct), BLANK(),
CumPct <= 0.70, "A",
CumPct <= 0.90, "B",
"C"
)
SWITCH(TRUE(), ...) evaluates each condition in order and returns the first match — it's the DAX equivalent of a CASE WHEN chain. The ISBLANK check handles rows where the product has no revenue in the current filter context (you don't want to classify a $0 product as "A").
Put this in a matrix or table with Product Name, Revenue, Cumulative Revenue %, and ABC Class. You should see products sorted by revenue, with A's at the top, then B's, then C's.
Fixed thresholds are fine for a one-off analysis, but business users will inevitably want to adjust them. "What if we define A as the top 60%?" "What if we lower the B cutoff to 85%?"
There are two approaches. We'll implement both.
In Power BI Desktop, go to the Modeling tab and click New Parameter. Create two parameters:
Power BI creates a disconnected table and a measure like Threshold A Value = SELECTEDVALUE('Threshold A'[Threshold A], 0.70) automatically. You get sliders out of the box.
Then update your classification measure:
ABC Class (Dynamic) =
VAR CumPct = [Cumulative Revenue %]
VAR ThreshA = [Threshold A Value]
VAR ThreshB = [Threshold B Value]
RETURN
SWITCH(
TRUE(),
ISBLANK(CumPct), BLANK(),
ThreshA >= ThreshB, "Config Error",
CumPct <= ThreshA, "A",
CumPct <= ThreshB, "B",
"C"
)
Notice the ThreshA >= ThreshB guard. If a user drags Threshold A above Threshold B, your classification would silently produce wrong results. Returning "Config Error" makes the misconfiguration visible.
For enterprise deployments where you want named threshold schemes ("Standard Pareto," "Strict A," "Supply Chain Optimized"), build a disconnected table:
| Scheme | Threshold_A | Threshold_B |
|---|---|---|
| Standard Pareto | 0.70 | 0.90 |
| Strict A Class | 0.60 | 0.85 |
| Supply Chain | 0.75 | 0.95 |
Add this table to your model (no relationships — it stays disconnected). Then create measures:
Selected Threshold A =
SELECTEDVALUE(
'ABC Thresholds'[Threshold_A],
0.70 -- default if nothing selected
)
Selected Threshold B =
SELECTEDVALUE(
'ABC Thresholds'[Threshold_B],
0.90
)
Connect these to a slicer on Scheme and your users get a clean dropdown instead of two independent sliders — which also prevents the invalid configuration problem because you control which combinations are available.
Tip
The configuration table approach also lets you add a "D" tier if needed (for products with literally zero sales). Just add a Threshold_C column and extend the SWITCH logic.
The per-product classification is useful, but decision-makers usually want the summary view: "How many A products do we have? What revenue do they represent?"
Count of A Products =
CALCULATE(
DISTINCTCOUNT(Sales[ProductKey]),
[ABC Class (Dynamic)] = "A"
)
Wait — this won't work as written. [ABC Class (Dynamic)] is a measure, not a column. You can't use a measure directly in a CALCULATE filter argument like that.
Here's the correct approach using FILTER:
Count of A Products =
COUNTROWS(
FILTER(
ALLSELECTED(Products),
[ABC Class (Dynamic)] = "A"
)
)
Similarly for revenue:
Revenue of A Products =
CALCULATE(
[Revenue],
FILTER(
ALLSELECTED(Products),
[ABC Class (Dynamic)] = "A"
)
)
Build one measure per tier (A, B, C), then create a summary card visual or a simple KPI table that shows tier counts and revenue at a glance.
Key insight
These summary measures are also dynamically filtering. If your report user selects "Electronics" in the Category slicer, these counts and revenues update to show A/B/C distribution within Electronics only. This is the power of building on measures rather than calculated columns.
The product-level pattern translates directly to customers. You just swap the dimension table. Here's the full set for customer classification:
Customer Revenue Rank =
VAR CurrentRevenue = [Revenue]
RETURN
RANKX(
ALLSELECTED(Customers),
[Revenue],
CurrentRevenue,
DESC,
Dense
)
Customer Revenue All Selected =
CALCULATE(
[Revenue],
ALLSELECTED(Customers)
)
Customer Cumulative Revenue % =
VAR CurrentRank = [Customer Revenue Rank]
VAR TotalRevenue = [Customer Revenue All Selected]
VAR CumulativeRevenue =
CALCULATE(
[Revenue],
FILTER(
ALLSELECTED(Customers),
[Customer Revenue Rank] <= CurrentRank
)
)
RETURN
DIVIDE(CumulativeRevenue, TotalRevenue)
Customer ABC Class =
VAR CumPct = [Customer Cumulative Revenue %]
VAR ThreshA = [Selected Threshold A]
VAR ThreshB = [Selected Threshold B]
RETURN
SWITCH(
TRUE(),
ISBLANK(CumPct), BLANK(),
CumPct <= ThreshA, "A",
CumPct <= ThreshB, "B",
"C"
)
If you want to reuse the same threshold slicers for both product and customer classification (reasonable if you're doing a holistic Pareto analysis), both sets of measures can share Selected Threshold A and Selected Threshold B.
If you drop your ABC Class measure into a matrix and turn on totals, the total row will evaluate [Cumulative Revenue %] without a specific product filter. What does that return?
In our current implementation: it iterates ALLSELECTED(Products), computes ranks for all of them, finds the maximum rank (since CurrentRank would be the rank for... no product in particular, which is undefined), and either returns BLANK or something nonsensical.
The fix is to detect when we're at the "all products" level using HASONEVALUE:
ABC Class (Dynamic) =
VAR CumPct = [Cumulative Revenue %]
VAR ThreshA = [Selected Threshold A]
VAR ThreshB = [Selected Threshold B]
RETURN
IF(
NOT HASONEVALUE(Products[ProductName]),
"Multiple", -- or BLANK() if you prefer
SWITCH(
TRUE(),
ISBLANK(CumPct), BLANK(),
ThreshA >= ThreshB, "Config Error",
CumPct <= ThreshA, "A",
CumPct <= ThreshB, "B",
"C"
)
)
Alternatively, return BLANK() for total rows and let the visual show nothing, which is often cleaner. This is one of those information function patterns that separates polished production measures from prototypes.
Let's put everything together into a focused build. Use the AdventureWorks or any retail dataset you have available. If you don't have one handy, you can use Power BI's built-in sample datasets (the Retail Analysis Sample works well).
Exercise: Build a Product ABC Dashboard
Part 1 — Foundation (15 minutes)
Revenue, Revenue All Products Selected, and Product Revenue Rank measures exactly as written above.Part 2 — Cumulative Logic (20 minutes)
Cumulative Revenue % measure.Product Revenue Rank ascending.Part 3 — Classification (15 minutes)
ABC Class (Dynamic) measure.Part 4 — Summary KPIs (10 minutes)
Count of A Products, Revenue of A Products, and equivalents for B and C.Bonus Challenge: Adapt the pattern to classify customers by revenue instead of products. Can you build a combined view that shows, for each Customer ABC tier, the breakdown of Product ABC tiers? (Hint: you'll need both sets of classification measures active simultaneously, and the nested FILTER calls will be more expensive — consider what ALLSELECTED scope means in that combined visual.)
If you use ALL(Products) everywhere instead of ALLSELECTED(Products), your ranks and cumulative percentages will always be computed against the full product catalog, ignoring all slicers. The measures will appear to work until a business user applies a category filter and notices that products in Electronics are still ranked against the full 50,000 SKU catalog.
Fix: Audit every occurrence of ALL(Products) in your ranking and cumulative measures. The only place ALL might be appropriate is if you specifically want global ranking regardless of filters — which is a valid but different analysis than what we're building here.
This usually means you computed the rank in a calculated column instead of a measure. Calculated columns evaluate at data refresh time in the filter context of the table row — they don't respond to report-time slicers. See DAX Fundamentals: When to Use Calculated Columns vs Measures in Power BI for a detailed breakdown of this distinction.
Fix: If you need the ABC class in a calculated column (e.g., for use in relationships or as a filter target), you must accept that it'll be static. Build a measure version for interactive reports and a column version for data export or relationship use cases.
If the last product in your list shows, say, 97% instead of 100%, you likely have a product with revenue that's being excluded from ALLSELECTED but not from [Revenue] for the base measure, or you have a ties issue with your rank.
Debug approach: Create a diagnostic measure:
Debug: Cumulative Revenue Amount =
VAR CurrentRank = [Product Revenue Rank]
VAR CumulativeRevenue =
CALCULATE(
[Revenue],
FILTER(
ALLSELECTED(Products),
[Product Revenue Rank] <= CurrentRank
)
)
RETURN CumulativeRevenue
Compare the value at the last rank against [Revenue All Products Selected]. If they differ, you have a scope mismatch somewhere.
The nested iterator pattern (FILTER inside CALCULATE, calling a measure that itself runs RANKX over ALLSELECTED) is O(n²) in the number of products. For 500 products, this is fine. For 50,000 products, it will be painfully slow.
Warning
Never deploy the naive nested FILTER/RANKX pattern against a product dimension with more than a few thousand rows without performance testing. Use DAX Studio to measure query times and look at the Server Timings to see where time is being spent.
The production workaround for large catalogs: pre-compute the rank in a calculated column at refresh time, then use that column in your cumulative measure. You lose some dynamism (the rank won't respond to category slicers), but for global ABC classification that doesn't change with filters, this is often the right trade-off:
-- Calculated column in Products table
Product Revenue Rank (Static) =
RANKX(
ALL(Products),
CALCULATE(SUM(Sales[Revenue])),
,
DESC,
Dense
)
Then in your cumulative measure, reference Products[Product Revenue Rank (Static)] instead of calling [Product Revenue Rank] inside the FILTER. This cuts the complexity dramatically because the rank is a simple column comparison rather than a measure invocation.
Some visual types (especially slicers and matrix row headers) behave unexpectedly when a measure returns BLANK. If you're using ABC Class as a slicer value or grouping dimension, you need a calculated column, not a measure. Create the column at refresh time with fixed thresholds, and supplement it with a dynamic measure for the detailed analysis view.
You've built a complete, production-ready ABC classification system in DAX. Here's what you accomplished:
Revenue, Revenue All Products Selected using ALLSELECTED for correct scopeProduct Revenue Rank with RANKX and ALLSELECTED, capturing current value in a variable to avoid context confusionCumulative Revenue % using nested FILTER/CALCULATE to accumulate revenue up to each product's rankABC Class (Dynamic) with SWITCH(TRUE()) logic, threshold parameters, total-row handling, and input validationThe pattern you've learned here is a template. Swap Products for Customers, SKUs for Vendors, Revenue for Profit — the structure is identical. The RANKX + ALLSELECTED + nested FILTER combination is the universal engine for any Pareto-based segmentation in DAX.
Where to go next:
The Pareto principle has been guiding business decisions for over a century. Now you can make it genuinely dynamic in Power BI.