
Here's a scenario that plays out in data teams constantly: you build a clean sales dashboard showing actuals by region, product, and month. The business loves it. Then someone asks, "But how are we doing compared to target?" You add a budget column. Then: "Can we also see how each region stacks up against the company average?" You add a peer comparison. Then: "Can the user choose which benchmark they're comparing against?" Now you're staring at a model that's starting to feel held together with duct tape — disconnected tables, conflicting relationships, and measures that only work in one visual but break in another.
The root problem is that benchmarks are fundamentally different animals from actuals. Your actuals table has one row per transaction. Your budget table has one row per region per month. Your peer group calculation is a dynamic aggregate. None of these want to live in the same table, and none of them connect to your data model the same way. If you try to force them into a single rigid schema, you end up with fragile relationships, duplicated data, and measures that only a single developer can maintain. What you need instead is a pattern that lets your data model stay clean while your measures stay flexible.
By the end of this lesson, you'll know exactly how to build that pattern. We'll use SELECTEDVALUE to detect user context, virtual relationships built with TREATAS and LOOKUPVALUE to connect disparate tables without polluting your schema, and a layered measure architecture that lets users switch between benchmark types on the fly. This is the kind of infrastructure that separates dashboards that answer follow-up questions from dashboards that create them.
What you'll learn:
SELECTEDVALUE to build benchmark-switching measures that respond to slicer contextTREATAS to connect tables without modifying your schemaLOOKUPVALUE for point-in-time budget lookups that survive complex filter contextsYou should be comfortable with:
CALCULATE changes context)FILTER, ALL, and CALCULATE patternsIf CALCULATE(SUM(...), FILTER(ALL(...), ...)) looks readable to you, you're in the right place.
Let's establish the scenario concretely. You're building a performance dashboard for a retail chain with 12 regions. The business tracks weekly sales, measures performance against an annual budget (distributed monthly), and wants each region to see how it compares against similar-sized stores — the peer group.
Your model has four tables:
Sales — transactional actuals
SaleID | Date | RegionKey | ProductCategory | Revenue | Units
DimDate — your standard date dimension, connected to Sales[Date]
DimRegion — your region dimension
RegionKey | RegionName | RegionSize | Territory | Manager
Budget — pre-loaded monthly targets by region and category
BudgetYear | BudgetMonth | RegionKey | ProductCategory | BudgetRevenue
BenchmarkType — a small disconnected table you'll create
BenchmarkID | BenchmarkLabel
1 | vs. Budget
2 | vs. Company Average
3 | vs. Peer Group
The BenchmarkType table has no relationship to anything. That's intentional. It acts as a parameter table — a slicer that lets users control measure behavior without filtering data. This is one of the most powerful patterns in DAX and one of the most underused.
In Power BI Desktop, you can create this table using Enter Data (Home ribbon → Enter Data) or by writing a calculated table. The calculated table approach is better for production models because it's version-controlled inside your PBIX and doesn't require manual data entry that someone might accidentally edit.
BenchmarkType =
DATATABLE(
"BenchmarkID", INTEGER,
"BenchmarkLabel", STRING,
{
{ 1, "vs. Budget" },
{ 2, "vs. Company Average" },
{ 3, "vs. Peer Group" }
}
)
Create this as a calculated table in your model. Add a single-select slicer to your report page using BenchmarkType[BenchmarkLabel]. No relationship to anything. Just a slicer that the user clicks.
Why no relationship? If you connected
BenchmarkTypeto your fact tables, every selection would filter your actuals — which you don't want. You want the actuals to always show, and only the benchmark measure to change. A disconnected parameter table letsSELECTEDVALUEread the user's choice without affecting any other calculation.
SELECTEDVALUE returns the single selected value in a column when exactly one value is in the filter context, and returns a default when zero or more than one value is selected. This makes it perfect for reading slicer selections in measures.
Here's the master benchmark measure that you'll build everything around:
Benchmark Revenue =
VAR SelectedBenchmark = SELECTEDVALUE(BenchmarkType[BenchmarkLabel], "vs. Budget")
RETURN
SWITCH(
SelectedBenchmark,
"vs. Budget", [Budget Revenue],
"vs. Company Average", [Company Average Revenue],
"vs. Peer Group", [Peer Group Average Revenue],
BLANK()
)
This is your router. The three measures it calls — [Budget Revenue], [Company Average Revenue], and [Peer Group Average Revenue] — are what we'll build in the following sections. This pattern is deliberately layered: each underlying measure handles its own complexity, and the router stays readable.
The SWITCH pattern with SELECTEDVALUE is also forgiving. If the user selects nothing, SELECTEDVALUE returns the default ("vs. Budget"), so your report doesn't go blank when the slicer is cleared.
This is where most practitioners hit their first wall. Your Budget table has one row per month, while your Sales table has one row per transaction. You need to aggregate actuals to match the budget granularity, or pull the budget down to match actuals — and neither is as simple as just creating a relationship.
The problem with a direct relationship between Sales and Budget on RegionKey is that it's a many-to-many through the date columns, and it forces the budget to filter the sales table in ways you don't want. The budget table shouldn't filter your actuals ever. You want to look up budget values given the current filter context, not let budget filter drive what sales rows appear.
TREATAS treats one table's column values as if they were the values of another column — essentially creating a relationship-like filter on the fly, inside a measure. Here's how you use it to pull budget figures that match whatever the current filter context has selected:
Budget Revenue =
CALCULATE(
SUM(Budget[BudgetRevenue]),
TREATAS(
VALUES(DimDate[Year]),
Budget[BudgetYear]
),
TREATAS(
VALUES(DimDate[MonthNumber]),
Budget[BudgetMonth]
),
TREATAS(
VALUES(DimRegion[RegionKey]),
Budget[RegionKey]
),
TREATAS(
VALUES(Sales[ProductCategory]),
Budget[ProductCategory]
)
)
Let's walk through what this is doing. When a user has filtered the report to, say, Region 3, Category "Electronics," and the month of March 2024, your filter context contains those values. VALUES(DimDate[Year]) returns {2024}. TREATAS then applies that as a filter on Budget[BudgetYear] — so only budget rows where BudgetYear = 2024 survive. The same logic applies for month, region, and category.
This is a virtual relationship. It doesn't exist in the model diagram. It's computed fresh every time the measure evaluates. The advantage is precision: you control exactly which columns participate in the join and in which direction. The model stays clean — no relationship arrow in your diagram — and the measure handles the cross-table logic explicitly.
Performance note:
TREATASwithVALUES()is generally efficient becauseVALUES()returns a small in-memory table when the filter context is already constrained by a visual. In a table visual with 50 rows, each cell computesTREATASonce per row. For very large budget tables (hundreds of thousands of rows), test with DAX Studio to confirm your query times are acceptable.
When you need to look up a single budget figure — say, a card visual showing the total annual target for a selected region — LOOKUPVALUE is cleaner:
Annual Budget Target =
LOOKUPVALUE(
Budget[BudgetRevenue],
Budget[BudgetYear], SELECTEDVALUE(DimDate[Year]),
Budget[RegionKey], SELECTEDVALUE(DimRegion[RegionKey]),
Budget[ProductCategory], SELECTEDVALUE(Sales[ProductCategory]),
0
)
LOOKUPVALUE fails (returns the default) if more than one row matches, which is actually a useful safety check. If a user selects multiple categories, the measure returns 0 instead of silently summing — a visible signal that the context is ambiguous. For aggregated contexts where multiple rows should sum, stick with the TREATAS+CALCULATE+SUM pattern above.
Now that you have both actuals and budget measures, the variance measures follow naturally:
Actual Revenue =
SUMX(Sales, Sales[Revenue])
Revenue vs Benchmark =
[Actual Revenue] - [Benchmark Revenue]
Revenue vs Benchmark % =
DIVIDE(
[Revenue vs Benchmark],
[Benchmark Revenue],
BLANK()
)
Notice the use of DIVIDE instead of the / operator. DIVIDE handles zero denominators gracefully — when budget is zero or blank, you get BLANK() instead of an error or infinity. This prevents visual artifacts in your table when some region-category combinations don't have budget entries.
The company average benchmark answers: "How does this region's performance compare to what the average region achieves?" The trick here is that you need to compute the average across all regions regardless of which region is currently selected in the filter context.
Company Average Revenue =
CALCULATE(
AVERAGEX(
DimRegion,
[Actual Revenue]
),
ALL(DimRegion)
)
ALL(DimRegion) removes any filter on the region dimension, so the AVERAGEX iterates over every region in the table and averages their individual [Actual Revenue] results. Other filters — like date range and product category — remain active because we only cleared DimRegion specifically.
This is an important subtlety: ALL(DimRegion) clears region-specific filters but respects everything else. So if the user has filtered to Q1 2024 and Electronics, the company average will be the average of all regions' Electronics revenue in Q1 2024. That's almost always the right behavior for a benchmark — you want to compare apples to apples on the time and category dimensions.
Watch out for ALL vs ALLSELECTED: If you use
ALLSELECTED(DimRegion)instead ofALL(DimRegion), the average only spans the regions visible in the current visual filter (e.g., from a region slicer). Whether that's right depends on your business question. "How does Region 3 compare to the regions I'm looking at?" usesALLSELECTED. "How does Region 3 compare to the entire company?" usesALL.
This is the most sophisticated benchmark and the most valuable one for regional managers who don't care about the global average — they want to know how they stack up against stores of similar size and type.
The peer group is defined by DimRegion[RegionSize], which contains values like "Small," "Medium," and "Large." A manager of a medium store doesn't want to be benchmarked against flagship locations. Here's how you build a peer group average that dynamically responds to the selected region:
Peer Group Average Revenue =
VAR CurrentRegionSize =
SELECTEDVALUE(DimRegion[RegionSize])
VAR PeerGroupRevenue =
CALCULATE(
AVERAGEX(
DimRegion,
[Actual Revenue]
),
ALL(DimRegion),
DimRegion[RegionSize] = CurrentRegionSize
)
RETURN
PeerGroupRevenue
Let's trace through this carefully. CurrentRegionSize captures the RegionSize of whichever region is currently selected. If a user is looking at Region 3 (a Medium store), CurrentRegionSize = "Medium". Then the CALCULATE block clears all region filters with ALL(DimRegion) and immediately re-applies a filter for only Medium stores. AVERAGEX then iterates over those Medium stores and computes their average revenue.
The resulting number is: "The average revenue across all Medium stores, for the same time period and product category currently selected." That's a genuinely useful benchmark for a regional manager.
A common requirement is that the peer group average should exclude the region being viewed — so a manager isn't compared against themselves. This makes the benchmark stricter and more honest:
Peer Group Average Revenue (Excl. Self) =
VAR CurrentRegionKey =
SELECTEDVALUE(DimRegion[RegionKey])
VAR CurrentRegionSize =
SELECTEDVALUE(DimRegion[RegionSize])
RETURN
CALCULATE(
AVERAGEX(
DimRegion,
[Actual Revenue]
),
ALL(DimRegion),
DimRegion[RegionSize] = CurrentRegionSize,
DimRegion[RegionKey] <> CurrentRegionKey
)
Warning: The
<>filter onRegionKeyinsideCALCULATEonly works reliably whenCurrentRegionKeyis a scalar (i.e., exactly one region is selected). If multiple regions are selected — or the measure is evaluated in a row context with no selection —SELECTEDVALUEreturnsBLANK(), andRegionKey <> BLANK()will include all rows, which is probably not what you want. Consider adding a guard:
Peer Group Average Revenue (Excl. Self) =
VAR CurrentRegionKey = SELECTEDVALUE(DimRegion[RegionKey])
VAR CurrentRegionSize = SELECTEDVALUE(DimRegion[RegionSize])
RETURN
IF(
ISBLANK(CurrentRegionKey),
BLANK(),
CALCULATE(
AVERAGEX(DimRegion, [Actual Revenue]),
ALL(DimRegion),
DimRegion[RegionSize] = CurrentRegionSize,
DimRegion[RegionKey] <> CurrentRegionKey
)
)
Here's the full measure set as it would exist in a production model, organized the way your measure pane should be structured — base measures first, then benchmark measures, then comparison measures:
-- BASE MEASURES
Actual Revenue = SUMX(Sales, Sales[Revenue])
Budget Revenue =
CALCULATE(
SUM(Budget[BudgetRevenue]),
TREATAS(VALUES(DimDate[Year]), Budget[BudgetYear]),
TREATAS(VALUES(DimDate[MonthNumber]), Budget[BudgetMonth]),
TREATAS(VALUES(DimRegion[RegionKey]), Budget[RegionKey]),
TREATAS(VALUES(Sales[ProductCategory]), Budget[ProductCategory])
)
Company Average Revenue =
CALCULATE(
AVERAGEX(DimRegion, [Actual Revenue]),
ALL(DimRegion)
)
Peer Group Average Revenue =
VAR CurrentRegionSize = SELECTEDVALUE(DimRegion[RegionSize])
RETURN
IF(
ISBLANK(CurrentRegionSize),
BLANK(),
CALCULATE(
AVERAGEX(DimRegion, [Actual Revenue]),
ALL(DimRegion),
DimRegion[RegionSize] = CurrentRegionSize
)
)
-- BENCHMARK ROUTER
Benchmark Revenue =
VAR SelectedBenchmark = SELECTEDVALUE(BenchmarkType[BenchmarkLabel], "vs. Budget")
RETURN
SWITCH(
SelectedBenchmark,
"vs. Budget", [Budget Revenue],
"vs. Company Average", [Company Average Revenue],
"vs. Peer Group", [Peer Group Average Revenue],
BLANK()
)
-- COMPARISON MEASURES
Revenue vs Benchmark = [Actual Revenue] - [Benchmark Revenue]
Revenue vs Benchmark % =
DIVIDE([Revenue vs Benchmark], [Benchmark Revenue], BLANK())
Benchmark Label =
SELECTEDVALUE(BenchmarkType[BenchmarkLabel], "vs. Budget")
The Benchmark Label measure is a small utility — a text measure you can drop into a card visual's title to dynamically show which benchmark is currently selected. It makes the report self-documenting.
Once you have individual region benchmarks, a natural next question is: where does a region sit in the distribution? Is being 5% above the peer group average exceptional, or is the top performer 40% above? To answer this, you can compute a region's percentile rank within its peer group.
Peer Group Rank =
VAR CurrentRevenue = [Actual Revenue]
VAR CurrentRegionSize = SELECTEDVALUE(DimRegion[RegionSize])
VAR RegionsAbove =
CALCULATE(
COUNTROWS(DimRegion),
ALL(DimRegion),
DimRegion[RegionSize] = CurrentRegionSize,
CALCULATE([Actual Revenue]) > CurrentRevenue
)
RETURN
RegionsAbove + 1
This returns the ordinal rank — 1 means best performer in the peer group. RegionsAbove counts how many peer regions have higher revenue than the current one, and adding 1 gives you rank (0 above you = rank 1).
Tip: This measure only makes sense in a row-by-row context — in a table visual showing one row per region. In an aggregated card visual,
SELECTEDVALUEmay returnBLANK()and the measure will returnBLANK(). That's acceptable behavior, but document it in your measure description so the next developer doesn't go hunting for a bug.
You've read the patterns. Now build them. Here's a structured exercise that takes approximately 45 minutes.
Scenario: You're working with a retailer that has 8 regions across 3 size categories (Small, Medium, Large). Revenue actuals, monthly budgets, and region metadata are available.
Step 1 — Build the data model. Create the following four tables using Enter Data in Power BI Desktop:
For Sales: create 50–100 rows with columns Date, RegionKey (values 1–8), ProductCategory (choose 3 categories), and Revenue (random values between $5,000 and $50,000).
For Budget: create one row per RegionKey × ProductCategory × Month combination for the current year, with BudgetRevenue set approximately 10% above your average actual for realism.
For DimRegion: create 8 rows with RegionKey, RegionName, RegionSize (assign Small/Medium/Large), and Territory.
For BenchmarkType: use the calculated table DAX from earlier in this lesson.
Step 2 — Build relationships. Connect Sales[Date] to DimDate[Date] and Sales[RegionKey] to DimRegion[RegionKey]. Leave Budget and BenchmarkType as floating tables (no relationships to the main model).
Step 3 — Build the measures in order. Start with Actual Revenue, then Budget Revenue using TREATAS, then the two average benchmarks, then the router and comparison measures. Test each measure in a table visual before building the next — don't try to debug five measures at once.
Step 4 — Build the report page. Create:
BenchmarkType[BenchmarkLabel] (single select)DimRegion[RegionName], [Actual Revenue], [Benchmark Revenue], [Revenue vs Benchmark], [Revenue vs Benchmark %], and [Peer Group Rank][Benchmark Label][Actual Revenue] and [Benchmark Revenue] by regionStep 5 — Validate the behavior. Switch between the three benchmark types and confirm:
The validation in Step 5 is the most important step. If "vs. Company Average" shows different numbers per region, you have a filter context bug in your ALL(DimRegion) usage.
Symptom: Your Budget Revenue measure shows blank everywhere, even when budget data exists.
Cause: VALUES(DimDate[Year]) returns a multi-column table if there are multiple years in scope, and TREATAS may not be matching as expected. Or your DimDate isn't connected to Sales, so date filters aren't propagating.
Fix: Check that DimDate has an active relationship to Sales[Date]. Then test the budget measure in a card with a single year selected. If it returns a value in the card but blanks in the table, the issue is in how the date context is being built in the table visual — verify that DimDate[Year] and DimDate[MonthNumber] are the exact column names you're referencing.
Symptom: Your benchmark router always falls to the default, even when the slicer is clicked.
Cause: You have a relationship between BenchmarkType and another table, which means clicking a benchmark also filters your data — and when data is filtered, the slicer context can become ambiguous.
Fix: Remove any relationships involving BenchmarkType. It must be completely disconnected. Confirm by right-clicking the table in model view and checking "Manage relationships."
Symptom: Selecting "vs. Peer Group" gives the same number as "vs. Company Average."
Cause: CurrentRegionSize is returning BLANK because no single region is selected — the visual shows all regions and no row-level selection exists when the measure evaluates at the grand total.
Clarification: This is actually correct behavior at the total level. When no single region is filtered, SELECTEDVALUE(DimRegion[RegionSize]) returns BLANK because multiple size values exist. Your IF guard returns BLANK, which shows as nothing. This is the right behavior — a grand total peer group is meaningless. The measure should only show values at the region row level.
Symptom: You get an error: "The column provided to TREATAS has a different data type."
Cause: Your DimDate[Year] column is a text type but Budget[BudgetYear] is an integer, or vice versa.
Fix: In Power Query, explicitly cast both columns to the same type before loading. Don't rely on implicit conversions in DAX — TREATAS requires matching data types.
Symptom: Negative variance shows as positive, or the % doesn't match the raw variance direction.
Cause: You subtracted in the wrong order: [Benchmark Revenue] - [Actual Revenue] instead of [Actual Revenue] - [Benchmark Revenue].
Convention: Positive variance = actual exceeds benchmark (good performance). Negative = underperformance. Always subtract [Benchmark Revenue] from [Actual Revenue] so the sign tells the story the business expects.
The pattern we've built is highly flexible but not free. A few things to keep in mind as your data scales:
TREATAS performance scales with table size. If your Budget table has 1 million rows (common in enterprise models with many SKUs), each TREATAS evaluation is scanning that table. Consider pre-aggregating your budget in Power Query to the level your reports actually need.
AVERAGEX over DimRegion is cheap. With 8–100 regions, iterating DimRegion in AVERAGEX is negligible. The expensive part is computing [Actual Revenue] for each row of the iteration — and that expense depends on how large your Sales table is. If the sales table is large, ensure your date and region columns are indexed and that your relationship directions support the filter propagation you need.
The SWITCH/SELECTEDVALUE router adds almost zero overhead. It's just a conditional on a single scalar value. Don't let concerns about the router pattern drive you toward inlining everything into one giant measure — the layered approach is faster to write, easier to debug, and performs identically.
You've built a complete dynamic benchmark system that lets users choose their comparison point from a slicer while the underlying model stays clean and the measures stay composable. Here's what you accomplished:
BenchmarkType as a parameter table — a disconnected slicer that controls measure behavior without filtering dataSELECTEDVALUE as a measure router — reading user intent and dispatching to the right calculationTREATAS for virtual relationships — connecting the budget table to your dimension filters without adding model relationshipsLOOKUPVALUE for single-value budget lookups — with implicit validation that fails gracefully on ambiguous contextALL and conditional filters — isolating comparable entities dynamically based on current selectionThe skills here transfer to a wide range of problems beyond benchmarks. The SELECTEDVALUE+SWITCH router pattern applies to any situation where users should control what a measure computes — metric switching, currency conversion selection, scenario comparison. The TREATAS virtual relationship pattern applies whenever you need to connect tables that shouldn't have permanent relationships. Master these two patterns and a large class of "impossible" DAX requirements become tractable.
Where to go next:
USERELATIONSHIP and RLS combination with peer group logic requires careful design — tackle this as an advanced follow-onLearning Path: DAX Mastery