Power BI Star Schema Design: What Your Data Model Should Look Like
April 20, 2026
By Tony Thomas
TL;DR: The DAX engine is a columnar, in-memory engine optimized for star schemas. When you model data as a star with clean fact and dimension tables, you work with VertiPaq instead of against it. A slow report with a star schema is a tuning problem. A slow report with a flat table or spaghetti relationships is an architecture problem you cannot tune your way out of.
Every Power BI performance problem I have debugged in the last three years traces back to the data model. Not the DAX. Not the visuals. The model.
A slow report with a star schema is a tuning problem — you look at query folding, cardinality, maybe an aggregation table. A slow report with a flat table or a web of many-to-many relationships is an architecture problem. You cannot tune your way out of a bad model. You rebuild it, or you accept the performance ceiling.
This is not a theoretical preference. The DAX engine — VertiPaq — is a columnar, in-memory engine optimized for star schemas. It compresses data by column, and compression works best when columns have low cardinality (few unique values relative to row count). Dimension tables have low-cardinality columns by nature. Fact tables have high-cardinality keys but compress well when they reference dimension tables through clean single-column relationships. When you model data as a star, you are working with VertiPaq, not against it.
When you model data as a flat denormalized table or a collection of loosely connected tables with bidirectional filtering, you are asking VertiPaq to do things it was not designed for. It will do them. Slowly.
What a Star Schema Actually Looks Like
A star schema has two types of tables:
Fact tables store the events or transactions you are measuring: sales, page views, support tickets, inventory movements. Each row is one event. Fact tables are tall (many rows) and narrow (few columns beyond the foreign keys and the measures). A well-designed fact table has:
- A surrogate key or composite key identifying the row
- Foreign keys referencing each related dimension table
- Numeric measure columns (amount, quantity, duration)
- Minimal or no descriptive text columns
Dimension tables store the attributes you filter and group by: customer name, product category, region, date. Dimension tables are wide (many descriptive columns) and short (relatively few rows compared to the fact table). A well-designed dimension table has:
- A primary key (usually a surrogate integer key)
- Descriptive columns that users will filter or slice by
- No measures or aggregated values
- A consistent grain (one row per customer, one row per product, one row per date)
The "star" shape comes from the diagram: one central fact table surrounded by dimension tables, each connected by a single relationship line. When you open Model View in Power BI Desktop and your diagram looks like a star, you are in good shape. When it looks like a web or a chain of tables linked end-to-end, you have work to do.
The Date Table: Non-Negotiable
Every Power BI semantic model needs a dedicated date dimension table. This is not optional. It is the foundation that DAX time intelligence functions require to work correctly.
The date table must satisfy three requirements:
-
Contiguous date range. Every date between the earliest and latest date in your fact table must be present. No gaps. If your sales data spans January 2023 through December 2025, your date table must have a row for every single day in that range, including days with no sales.
-
Marked as a date table. In Power BI Desktop, right-click the table and select "Mark as Date Table." Specify the date column. This tells the DAX engine to use this table for time intelligence, and it enables functions like
SAMEPERIODLASTYEAR,TOTALYTD, andDATEADDto work correctly. -
Granularity matches your analysis. If you analyze data at the daily level, the date table has one row per day. If monthly, one row per month. Daily is the most common and most flexible.
What goes in a date table:
| Column | Example | Purpose |
|---|---|---|
| Date | 2025-03-15 | Primary key, the date itself |
| Year | 2025 | Year filter/slicer |
| Quarter | Q1 | Quarter filter |
| Month Number | 3 | Sorting month names correctly |
| Month Name | March | Display in charts |
| Week Number | 11 | ISO week filter |
| Day of Week | Saturday | Weekday analysis |
| Is Weekend | TRUE | Weekend/weekday filtering |
| Fiscal Year | FY2025 | If fiscal year differs from calendar |
| Fiscal Quarter | FQ4 | Fiscal quarter mapping |
Generate this table in Power Query, not in DAX. A calculated table using CALENDAR() or CALENDARAUTO() works but prevents query folding and adds processing time during refresh. A Power Query date table is static, folds correctly, and loads faster.
The most common date table mistake is letting Power BI auto-detect it. Power BI's auto date/time feature creates hidden date tables for every date column in your model. These tables are invisible in the field list but consume memory and create confusion when DAX time intelligence functions behave unexpectedly. Turn off auto date/time in File > Options and settings > Options > Current File > Data Load and use an explicit date table instead.
Relationships: One-to-Many, Single Direction, No Exceptions (Almost)
The ideal Power BI relationship is:
- One-to-many from the dimension table (one side) to the fact table (many side)
- Single cross-filter direction from dimension to fact
- Active (not inactive)
- Based on a single column (not a composite key)
This pattern gives the DAX engine a clean, predictable filter propagation path. When a user selects "North America" in a region slicer, the filter flows from the Region dimension table through the relationship to the fact table, and only rows where Region = "North America" are included in the calculation. Simple, fast, correct.
Where it gets complicated:
Many-to-many relationships. Power BI supports them, but they come with a performance cost and a correctness risk. A many-to-many relationship between a fact table and a dimension means the engine cannot use the dimension's primary key to look up fact rows directly. Instead, it performs a cross-join filtered by the matching values, which is computationally more expensive.
More dangerously, many-to-many relationships can produce inflated or deflated totals depending on the filter context. If a sale is associated with multiple categories through a many-to-many relationship, the total sales measure will count that sale once for each category but will also need to deduplicate at the grand total level. Whether it deduplicates correctly depends on how the DAX is written. This is the source of the "my total does not match the sum of the parts" question that appears in every Power BI forum.
The fix is usually a bridge table. If a product belongs to multiple categories, create a Product-Category bridge table with one row per product-category combination. The product dimension has a one-to-many relationship to the bridge table, and the bridge table has a many-to-one relationship to the category dimension. The relationships are all one-to-many, the filter propagation is predictable, and the totals are correct.
Bidirectional cross-filtering. Power BI allows you to set a relationship to filter in both directions. This is occasionally necessary (particularly for row-level security scenarios), but it should be the exception, not the default. Bidirectional filtering creates ambiguous filter paths when the model has more than one path between two tables. It also degrades performance because the engine must evaluate filter propagation in both directions.
If you find yourself enabling bidirectional filtering to make a visual work, step back and ask whether the model structure is correct. Nine times out of ten, the issue is a missing relationship or a missing bridge table, not a filtering direction problem.
Naming Conventions That Scale
In a model with 5 tables, naming barely matters. In a model with 40 tables, it is the difference between a model that developers can navigate and one where every DAX formula requires 10 minutes of exploration to understand.
Tables: Use clear, singular nouns. Customer, not Customers or tbl_Customers or dim_Customer. The dim_ and fact_ prefixes are a Kimball data warehouse convention that made sense when tables lived in SQL Server alongside hundreds of other objects. In a Power BI semantic model, every table is either a dimension or a fact, and the model diagram makes the distinction visually. The prefixes add noise without information.
Exception: if your organization has an established naming convention that uses prefixes, follow it. Consistency across the organization is more valuable than following this guide's preference.
Columns: Use spaces and proper casing for display-friendly names. Customer Name, not CustomerName or customer_name. Power BI shows column names directly in visual field wells and legends. Names that read naturally produce cleaner reports without requiring display name overrides.
Measures: Group measures in a dedicated display folder or a dedicated measures table (a disconnected table with no relationships). Name measures descriptively: Total Revenue, YTD Revenue, Revenue vs Prior Year %. The name should tell the user exactly what the measure calculates without opening the formula.
Avoid abbreviations unless they are universally understood in your domain. Qty is fine for quantity. CustSegPctChgYoY is not fine for "Customer Segment Percentage Change Year over Year."
Common Model Shapes and Their Problems
The Flat Table
One massive table with every column — sales amount, customer name, product category, region, date, all in one place. This is the most common anti-pattern in Power BI models, usually inherited from an Excel workflow where everything lived in one sheet.
Why it is slow: The columns that would be dimensions (customer name, product category, region) are repeated for every row in the fact. If you have 10 million sales rows and 500 customers, the customer name column stores 10 million values instead of 500. VertiPaq compression is less effective because the column cardinality is artificially inflated by repetition.
Why it is incorrect: Slicer and filter behavior can produce unexpected results when attributes that should be independent (customer name, product category) are co-located in the same table. Filtering by one attribute implicitly filters the other through row context, which is not always the intended behavior.
Fix: Split the table into a fact table and dimension tables using Power Query. Group by the natural key of each dimension, extract the unique attribute combinations, and replace the descriptive columns in the fact table with foreign key references.
The Snowflake
A star schema where dimension tables have their own child dimension tables. Region to Country to State to City, each as a separate table. This is normalized relational modeling, and it is correct from a database design perspective.
Why it is unnecessary in Power BI: VertiPaq is a columnar engine. Adding table joins adds query complexity without improving compression. The State and City columns compress just as well (often better) when denormalized into the Region dimension table. Flatten snowflake dimensions into single dimension tables unless you have a specific reason not to (such as row-level security at different granularities).
The Spaghetti Model
Tables connected through chains of relationships with no clear star pattern. Table A relates to Table B, which relates to Table C, which relates to Table D. Some relationships are bidirectional. Some are inactive. Nobody remembers why Table B exists.
Why it fails: The DAX engine traces filter paths through the relationship chain to propagate filters from slicers to measures. Longer chains mean more complex filter propagation. Ambiguous paths (multiple routes between two tables) force the engine to choose, and it may not choose what you intended.
Fix: Identify your fact tables (the tables with the measures you need to calculate) and your dimension tables (the tables with the attributes you filter by). Restructure so every dimension connects directly to the fact table. Eliminate intermediate tables by denormalizing them into the dimension they belong to.
How to Audit Your Existing Model
If you inherited a semantic model and need to assess its health before making changes, the audit process is:
-
Open Model View. Does the diagram look like a star, a snowflake, or spaghetti? This is the fastest diagnostic.
-
Check relationship types. Are they all one-to-many, single direction? Flag any many-to-many or bidirectional relationships for review.
-
Verify the date table. Is there a dedicated date table? Is it marked as a date table? Does it cover the full date range of the fact data?
-
Look for flat tables. Any table with more than 15-20 columns that mixes descriptive attributes with numeric measures is likely a candidate for splitting into fact and dimension tables.
-
Check cardinality. In Power Query, check the distinct count of key columns. If a "dimension" table has the same row count as the fact table, it is not really a dimension — it is either a denormalized fact or it needs to be aggregated.
-
Review measure organization. Are measures scattered across multiple tables or organized in a dedicated location? Scattered measures are a maintenance burden and a sign that the model grew organically without planning.
For a tool-assisted approach, Model Studio's AI Assessment feature reads your TMDL schema and flags star schema violations, missing date tables, problematic relationships, and naming convention issues. It works from the schema metadata only — no data leaves your environment. The assessment produces a structured report you can use as a remediation checklist.
When to Deviate from Star Schema
Star schema is the default, not the only option. There are legitimate reasons to deviate:
Aggregation tables. Pre-aggregated fact tables at a higher grain (monthly instead of daily) are a performance optimization that introduces a second fact table at a different granularity. This is a well-understood pattern supported by Power BI's aggregation feature.
Role-playing dimensions. A single Date dimension used for both Order Date and Ship Date requires two relationships to the same fact table — one active, one inactive. This is standard and handled cleanly with USERELATIONSHIP in DAX.
Row-level security at different granularities. If some users see data at the region level and others at the country level, you may need separate dimension tables or security tables that do not fit a pure star pattern.
DirectQuery models. DirectQuery models send queries back to the source database, and the source database may not be modeled as a star. In these cases, the model shape is constrained by the source, and the optimization strategies are different (push query performance to the source, minimize the number of relationships that require cross-database joins).
In each case, the deviation is intentional and documented. The problem is not having a non-star model. The problem is having a non-star model by accident because nobody designed the model before building the report.
Further Reading
- Using TMDL to Drive Power BI Report Design — How TMDL provides the schema representation that enables AI-assisted model design
- How to Audit a Power BI Semantic Model with AI — Using Model Studio to assess model health and identify structural issues
- Star schema and the importance for Power BI — Microsoft Learn — Microsoft's official guidance on star schema design for Power BI
- The Data Warehouse Toolkit — Ralph Kimball — The foundational reference on dimensional modeling
Want to see how your semantic model scores against star schema best practices? Upload your TMDL to Model Studio and get an instant structural assessment. No data required — schema only. Works with any Power BI license.
Founder of Draft BI, building the design-first companion for Power BI report development. Writing about PBIR, WCAG accessibility, DAX measures, and the workflows that help Power BI developers and analysts deliver better reports faster.