Star schema & relationship cardinality
A Power BI model is not a spreadsheet and not a normalised database. The shape that makes both DAX and the VertiPaq engine happy is the star schema: one narrow fact table in the middle, surrounded by short dimension tables.
Fact and dimension tables
For Rajlaxmi Distributors:
| Table | Type | Grain / contents |
|---|---|---|
Sales |
Fact | One row per invoice line: InvoiceDate, StoreKey, ProductKey, Qty, NetAmount |
Product |
Dimension | One row per SKU: ProductKey, Brand, Category, Pack, MRP |
Store |
Dimension | One row per kirana store: StoreKey, StoreName, City, Region, Beat |
Date |
Dimension | One row per calendar day |
Salesperson |
Dimension | One row per field executive |
Facts hold numbers and keys; dimensions hold the text you slice by. Keep descriptive columns out of the fact
table — a Brand column repeated across 8 million invoice lines compresses far worse than a 400-row Product table.
Cardinality and filter direction
Relationships are created by dragging Product[ProductKey] onto Sales[ProductKey]. Power BI then asks two things:
- Cardinality — almost always one-to-many (1:*), with the one side on the dimension.
- Cross-filter direction — Single by default. Filters flow down the "one" side into the fact table, never back up.
That direction is the whole point. Selecting Brand = "Parle" on a slicer filters Product to a few rows, which
filters Sales to those rows, and SUM(Sales[NetAmount]) recalculates. Selecting a sale does not filter
Product.
The bi-directional trap
Setting cross-filter to Both looks like a quick fix when a slicer "does not work". It introduces two real problems:
- Ambiguity. With two fact tables (
SalesandTargets) both joined toStoreandDate, a bi-directional relationship creates more than one path between tables. The engine either refuses the model or silently picks a path, and totals stop matching the source system. - Performance. Every filter now propagates in both directions, expanding the work VertiPaq does per visual.
Use Single everywhere, and when you genuinely need upward filtering, do it in one measure with
CROSSFILTER or TREATAS rather than changing the model for everyone.
Role-playing dimensions
Sales has both an InvoiceDate and a DeliveryDate. You cannot have two active relationships to Date. Create
both, leave the delivery one inactive (dashed line), and activate it inside a measure:
Delivered Value =
CALCULATE (
SUM ( Sales[NetAmount] ),
USERELATIONSHIP ( Sales[DeliveryDate], 'Date'[Date] )
)
Key takeaways
- One fact table, many small dimensions, single-direction 1:* relationships.
- Hide key columns (
ProductKey,StoreKey) from report view so users slice byBrand, not by a number. - Avoid many-to-many cardinality unless you truly have a bridge table; it changes blank-handling and totals.
- Never join two dimensions directly to each other — join both to the fact instead.
- Snowflaking (
Product → Category → Division) works but adds hops; flatten it in Power Query when you can.
Practice
Build the five-table model above from flat files. Add a Targets table at store-and-month grain, join it to
Store and Date, then temporarily set one relationship to Both and observe how the store-wise target total
changes. Revert to Single and fix the visual with a measure instead.