All guides

Bidirectional relationships: when they silently corrupt totals

Why 'both directions' cross-filtering on a dimension breaks counts and totals, how to see it in a report, and what to use instead.

6 min read · updated 2026-09-21

What it is

A relationship normally filters one way: the dimension filters the fact. Setting cross-filter direction to Both lets the fact filter the dimension too. It looks harmless in Model view and it is the first thing people reach for when a slicer 'does not filter the other slicer'.

The problem is not the slicer. It is every measure that now sees a dimension whose rows have been reduced by another fact table, or by a second dimension through the fact.

How it breaks

Take DimCustomer filtered both ways from a Sales fact and a Budget fact. A card showing COUNTROWS(DimCustomer) no longer counts customers: it counts customers with sales in the current selection. Filter a product and the customer count changes even though the customer table did not.

Worse, two facts filtering the same dimension both ways create ambiguity. The engine picks a path, the path can change when you add a table, and the total on a matrix stops matching the sum of its rows. These bugs survive testing because every individual row looks plausible.

Bidirectional filters also make every query slower: the engine has to propagate filters through the fact table, which is the largest table in the model.

What to use instead

For slicer sync ('only show products that this customer bought'), keep the relationship single-direction and put a measure on the slicer's visual-level filter: `Products bought = COUNTROWS(Sales)` with the filter 'is not blank'. That gives the same behaviour without touching the model.

For a specific measure that genuinely needs the reverse path, use CROSSFILTER inside CALCULATE. The change is scoped to that measure, documented in its DAX, and invisible to the rest of the model.

For many-to-many between two dimensions, build a bridge table and filter it single-direction from both sides, or move the attribute onto the fact.

Scoped instead of global
Customers with sales =
CALCULATE (
    COUNTROWS ( DimCustomer ),
    CROSSFILTER ( Sales[CustomerKey], DimCustomer[CustomerKey], BOTH )
)

What the audit checks

The static audit flags every relationship set to Both, with a higher severity when the relationship touches a fact table or when two bidirectional paths reach the same dimension. The AI review then reads the measures that depend on those tables and tells you which totals are actually affected.

Detected by

Checks relationship-direction, model-bidirectional-with-rls among the 363 static checks, plus the AI review for the cases static analysis cannot prove.

Next: Fact table on the 'one' side: the cardinality mistake that breaks every slicer