Power BI · Beginner

Power BI star schema and your first report

13 min read

Why it matters

A Power BI star schema is the difference between a report that is right, fast and easy to extend and one that breaks the first time someone asks a new question. Power BI was named in 80% of the Data Analyst job descriptions we analysed; data modelling was named in 26% of them in its own right, and Microsoft's PL-300 exam gives "Model the data" 25 to 30% of the marks. "Why a star schema?" and "calculated column or measure?" are the two Power BI interview questions you are most likely to hear.

The concept

The theory of dimensional modelling (grain, conformed dimensions, slowly changing dimensions) lives in Data modelling. Here you apply it in Power BI.

Facts and dimensions

  • A fact table records events at a fixed grain (one row per invoice line). It holds keys and numbers you add up: quantity, price, revenue. It is long and narrow.

  • A dimension table describes the things involved in the events: who (Customer), what (Product), when (Date). One row per key, many descriptive columns. It is short and wide.

  • One-to-many relationships run from each dimension's key (the one side, values unique) to the fact table's matching column (the many side).

Draw it and you get a star: the fact in the middle, dimensions around it.

Why Power BI wants a star

QuestionOne flat tableStar schema
Filter by countryWorksWorks
Count customers who bought nothing this monthImpossible: customers without sales have no rowsCustomer table has every customer
Add a second fact table (returns, budget)Needs a second flat table that cannot share slicersBoth facts share the same dimensions and slicers
Time intelligence (YTD, last year)Needs a date column with every day; sales dates have gapsThe Date table has every day
Model sizeCountry text repeated on 397,924 rowsCountry stored once per customer; VertiPaq compresses the keys
DAX complexityFilters on the fact table need ALL tricksFilters flow from dimensions to the fact along relationships

The engine inside Power BI (VertiPaq) and the DAX language were designed around this shape. Most "DAX is hard" problems in real projects turn out to be model problems.

Build dimensions by referencing one cleaned query

In Power Query, Reference creates a new query that starts from the output of another, so cleaning written once feeds every table. Duplicate copies all the steps instead, so a fix in one copy does not reach the others. For a fact and its dimensions, clean once in a staging query and reference it.

Dimension keys must be unique, case-insensitively

A one-to-many relationship needs unique values on the one side. Two traps make "Remove duplicates" in Power Query not enough:

  1. The attribute differs between rows. In this dataset 8 customers appear under two countries and 213 stock codes have more than one description. Remove duplicates on the key keeps whichever row comes first, which is not guaranteed to be the same on every refresh. Pick a rule (the latest value is a common one) and implement it with Group By.

  2. Power Query is case-sensitive, the model is not. The raw file contains both 85123A and 85123a. Power Query sees two keys; VertiPaq compares text without case and sees a duplicate, and Power BI refuses to create the one-to-many relationship. Upper-case text keys before grouping.

A date table, marked as a date table

Time-based analysis needs a table with one row per day, no gaps, covering whole years. You create it in DAX with Modeling, New table, then Table tools, Mark as date table, choosing its date column. Marking tells Power BI that this is the calendar, which time intelligence relies on and which disables the hidden auto date/time tables for that column. The next lesson but one goes deeper (fiscal years, ISO weeks, holidays).

The relationship key must be a date, not a datetime

InvoiceDate holds a time: 2010-12-01 08:26. The Date table holds midnight dates: 2010-12-01 00:00. A relationship between them matches almost nothing, and every visual by month shows blank. Create a date-only column in the fact table, ideally in Power Query (DateTime.Date), and relate that.

Sort by column

A text column such as Month ("Jan 2011") sorts alphabetically: Apr, Aug, Dec. Select the column in Table view and use Column tools, Sort by column with a numeric column such as Year Month (201101). Do the same for weekday names.

Calculated column or measure

Calculated columnMeasure
ComputedAt refresh, once per row, stored in the modelAt query time, for each cell of each visual
SeesThe current row (row context)The filters on the visual (filter context)
CostsMemory and refresh timeCPU when a visual renders
Use it toSlice, filter, group or relate: anything that goes on rows, axes, slicers or relationshipsAggregate: anything you add up, divide or compare
Example hereProduct[Is Non-Product] for a slicerRevenue, Customers, Revenue per Customer

Rule of thumb: if it goes on an axis or a slicer, it is a column (and if possible, build it in Power Query instead of DAX). If it is a number that must respond to slicers, it is a measure.

Worked example: Online Retail as a star

This uses the same Online Retail CSV as Power BI Desktop tutorial for beginners (UCI, CC BY 4.0), loaded from its GitHub URL. Start a new file.

Step 1: one staging query

Get data, Web, paste the URL, Transform data, name the query Retail Lines and replace its code with:

Retail
let
    Source = Csv.Document(
        Web.Contents("https://raw.githubusercontent.com/databricks/Spark-The-Definitive-Guide/master/data/retail-data/all/online-retail-dataset.csv"),
        [Delimiter = ",", Columns = 8, Encoding = 65001, QuoteStyle = QuoteStyle.Csv]
    ),
    Promoted = Table.PromoteHeaders(Source, [PromoteAllScalars = true]),
    Typed = Table.TransformColumnTypes(
        Promoted,
        {
            {"InvoiceNo", type text}, {"StockCode", type text}, {"Description", type text},
            {"Quantity", Int64.Type}, {"InvoiceDate", type datetime}, {"UnitPrice", type number},
            {"CustomerID", type text}, {"Country", type text}
        },
        "en-US"
    ),
    WithCustomer = Table.SelectRows(Typed, each [CustomerID] <> null and [CustomerID] <> ""),
    UpperCodes = Table.TransformColumns(WithCustomer, {{"StockCode", Text.Upper, type text}})
in
    UpperCodes

"en-US" makes 12/1/2010 8:26 mean 1 December whatever your laptop's locale. Lines without a customer are dropped here because every table in this model is about customers; the tutorial showed there are 135,080 of them. Untick Enable load on this query.

Step 2: the fact table

Right-click Retail Lines, Reference, name the new query Sales:

Sales.pq
let
    Source = #"Retail Lines",
    NotCancelled = Table.SelectRows(Source, each not Text.StartsWith([InvoiceNo], "C")),
    WithDay = Table.AddColumn(NotCancelled, "InvoiceDay", each DateTime.Date([InvoiceDate]), type date),
    WithRevenue = Table.AddColumn(WithDay, "Revenue", each [Quantity] * [UnitPrice], type number),
    Kept = Table.SelectColumns(
        WithRevenue,
        {"InvoiceNo", "StockCode", "CustomerID", "InvoiceDay", "Quantity", "UnitPrice", "Revenue"}
    )
in
    Kept

Description and Country are gone from the fact: they belong to the dimensions. The time of day is gone too; nobody in this business analyses by minute, and dropping it shrinks the model.

Step 3: Customer and Product dimensions

Reference Retail Lines twice more:

Customer.pq
let
    Source = #"Retail Lines",
    // One row per customer; the country on their most recent line wins
    Grouped = Table.Group(
        Source,
        {"CustomerID"},
        {{"Country", each Table.Max(_, "InvoiceDate")[Country], type text}}
    )
in
    Grouped
Product.pq
let
    Source = #"Retail Lines",
    // One row per stock code; the most recent description wins
    Grouped = Table.Group(
        Source,
        {"StockCode"},
        {{"Description", each Table.Max(_, "InvoiceDate")[Description], type text}}
    )
in
    Grouped

Table.Max(_, "InvoiceDate") returns the latest row of each group, so the rule is the same on every refresh. Both dimensions come from all lines with a customer, including cancellations, so a customer who only ever returned goods still exists in Customer. Close & Apply.

Step 4: the Date table

Modeling, New table:

Date.dax
Date =
ADDCOLUMNS (
    CALENDAR ( DATE ( 2010, 1, 1 ), DATE ( 2011, 12, 31 ) ),
    "Year", YEAR ( [Date] ),
    "Month Number", MONTH ( [Date] ),
    "Month", FORMAT ( [Date], "mmm yyyy" ),
    "Year Month", YEAR ( [Date] ) * 100 + MONTH ( [Date] ),
    "Weekday", FORMAT ( [Date], "ddd" ),
    "Weekday Number", WEEKDAY ( [Date], 2 )
)

Then:

  1. Table tools, Mark as date table, date column Date.

  2. Select Month, Column tools, Sort by column, Year Month. Select Weekday, sort by Weekday Number (WEEKDAY(..., 2) numbers Monday as 1).

FORMAT returns month and day names in the model's language, so a model created on a Hindi-locale machine shows Hindi month names. That is usually what you want, but check before you build a slicer on text values.

Step 5: relationships

In Model view drag:

  • Customer[CustomerID] to Sales[CustomerID]

  • Product[StockCode] to Sales[StockCode]

  • Date[Date] to Sales[InvoiceDay]

Each should show cardinality one to many (1 on the dimension, * on Sales) and cross filter direction Single, with the arrow pointing at Sales. Then hide the key columns on the fact table (right-click, Hide in report view) so report builders use the dimension columns instead.

Step 6: measures and a calculated column

Home, New measure, one at a time:

Sales
Revenue = SUM ( Sales[Revenue] )

Orders = DISTINCTCOUNT ( Sales[InvoiceNo] )

Customers = DISTINCTCOUNT ( Sales[CustomerID] )

Revenue per Customer = DIVIDE ( [Revenue], [Customers] )

And one calculated column on Product (Table tools, New column), because it is meant for a slicer:

Product.Is
Is Non-Product =
Product[StockCode] IN { "POST", "DOT", "M", "C2", "BANK CHARGES", "PADS", "CRUK", "D" }

These codes are postage, carriage, manual adjustments, bank charges and similar lines that are not goods. A slicer on this column lets a buyer look at merchandise only.

Step 7: one page

A card each for Revenue, Orders, Customers and Revenue per Customer; a column chart of Revenue by Date[Month]; a bar chart of Revenue by Customer[Country] with a Top N filter of 10 on the visual; and a slicer on Customer[Country].

Step 8: prove the numbers

Before anyone sees the page, reconcile it. This script builds the same four tables with the same rules in DuckDB and computes every number on the page:

check_star.py
import duckdb

con = duckdb.connect()
con.sql("""
    create table retail_lines as
    select InvoiceNo, upper(StockCode) as StockCode, Description, Quantity,
           InvoiceDate, UnitPrice, CustomerID, Country
    from read_csv('online-retail.csv',
                  types = {'InvoiceNo': 'VARCHAR', 'StockCode': 'VARCHAR', 'CustomerID': 'VARCHAR'},
                  timestampformat = '%m/%d/%Y %H:%M')
    where CustomerID is not null
""")
# Fact: invoice lines that are not cancellations
con.sql("""
    create table Sales as
    select InvoiceNo, StockCode, CustomerID, cast(InvoiceDate as date) as InvoiceDay,
           Quantity, UnitPrice, Quantity * UnitPrice as Revenue
    from retail_lines where not starts_with(InvoiceNo, 'C')
""")
# Dimensions: one row per key, attribute taken from the latest line
con.sql("create table Customer as select CustomerID, arg_max(Country, InvoiceDate) as Country from retail_lines group by CustomerID")
con.sql("create table Product as select StockCode, arg_max(Description, InvoiceDate) as Description from retail_lines group by StockCode")

for t in ["Sales", "Customer", "Product"]:
    print(t, "rows:", con.sql(f"select count(*) from {t}").fetchone()[0])
print("duplicate keys:",
      con.sql("select count(*) - count(distinct CustomerID) from Customer").fetchone()[0],
      con.sql("select count(*) - count(distinct StockCode) from Product").fetchone()[0])

print(con.sql("""
    select round(sum(Revenue), 2) as Revenue,
           count(distinct InvoiceNo) as Orders,
           count(distinct CustomerID) as Customers,
           round(sum(Revenue) / count(distinct CustomerID), 2) as "Revenue per Customer"
    from Sales
"""))
print(con.sql("""
    select c.Country, round(sum(s.Revenue), 2) as Revenue
    from Sales s join Customer c using (CustomerID)
    group by c.Country order by Revenue desc limit 5
"""))
print(con.sql("""
    select strftime(InvoiceDay, '%b %Y') as Month, round(sum(Revenue), 2) as Revenue
    from Sales group by Month, date_trunc('month', InvoiceDay)
    order by date_trunc('month', InvoiceDay)
"""))
print(con.sql("""
    select p.Description, round(sum(s.Revenue), 2) as Revenue
    from Sales s join Product p using (StockCode)
    group by p.Description order by Revenue desc limit 5
"""))
output
Sales rows: 397924
Customer rows: 4372
Product rows: 3684
duplicate keys: 0 0
┌───────────┬────────┬───────────┬──────────────────────┐
│  Revenue  │ Orders │ Customers │ Revenue per Customer │
│  double   │ int64  │   int64   │        double        │
├───────────┼────────┼───────────┼──────────────────────┤
│ 8911407.9 │  18536 │      4339 │              2053.79 │
└───────────┴────────┴───────────┴──────────────────────┘
 
┌────────────────┬────────────┐
│    Country     │  Revenue   │
│    varchar     │   double   │
├────────────────┼────────────┤
│ United Kingdom │ 7308391.55 │
│ Netherlands    │  285446.34 │
│ EIRE           │   265545.9 │
│ Germany        │  228867.14 │
│ France         │  209024.05 │
└────────────────┴────────────┘
 
┌──────────┬────────────┐
│  Month   │  Revenue   │
│ varchar  │   double   │
├──────────┼────────────┤
│ Dec 2010 │  572713.89 │
│ Jan 2011 │  569445.04 │
│ Feb 2011 │  447137.35 │
│ Mar 2011 │  595500.76 │
│ Apr 2011 │  469200.36 │
│ May 2011 │  678594.56 │
│ Jun 2011 │  661213.69 │
│ Jul 2011 │  600091.01 │
│ Aug 2011 │   645343.9 │
│ Sep 2011 │  952838.38 │
│ Oct 2011 │ 1039318.79 │
│ Nov 2011 │ 1161817.38 │
│ Dec 2011 │  518192.79 │
└──────────┴────────────┘
 
┌────────────────────────────────────┬───────────┐
│            Description             │  Revenue  │
│              varchar               │  double   │
├────────────────────────────────────┼───────────┤
│ PAPER CRAFT , LITTLE BIRDIE        │  168469.6 │
│ REGENCY CAKESTAND 3 TIER           │ 142592.95 │
│ CREAM HANGING HEART T-LIGHT HOLDER │  100603.5 │
│ JUMBO BAG RED RETROSPOT            │  85220.78 │
│ MEDIUM CERAMIC TOP STORAGE JAR     │  81416.73 │
└────────────────────────────────────┴───────────┘

Your cards should read 8.91M revenue, 18,536 orders, 4,339 customers and 2,053.79 revenue per customer. Notice:

  • Customer has 4,372 rows but Sales has 4,339 customers. The other 33 only appear on cancelled invoices. That is the star schema working: the dimension knows customers the fact table does not.

  • Revenue moved from 9.75M to 8.91M. Cancellations and lines without a customer are out.

  • The top product is suspicious. PAPER CRAFT, LITTLE BIRDIE earns 168,469.60 from a single line of 80,995 units, which the same customer cancelled minutes later (the cancellation sits in the lines you filtered out). The fifth row has the same story. This is why a later lesson brings returns in as a second fact table.

Common mistakes

  • Relating on a datetime column. Months show blank or only midnight transactions match. Relate on a date-only column.

  • Remove duplicates on a dimension with conflicting attributes. The kept row can change between refreshes, so a customer's country flips. Group by the key with an explicit rule.

  • Forgetting case. 85123A and 85123a are two keys to Power Query and one to the model. Upper-case keys in the staging query.

  • Duplicate instead of Reference. Five copies of the cleaning steps drift apart within a month. Reference one staging query.

  • Keeping descriptive columns on the fact. Country on 397,924 rows wastes memory and tempts people to filter the fact directly. Keep descriptions in dimensions and hide fact keys.

  • A Date table that does not cover whole years. Time intelligence functions expect full years. Start on 1 January (or the first day of your fiscal year) and end on 31 December.

  • Shipping without reconciling. One total, one breakdown and one top-N list checked against an independent query catch most model errors.

Interview angle

Why do we use a star schema in Power BI?

Because filters flow from dimensions to facts along one-to-many relationships, which is how VertiPaq and DAX are designed to work. A star keeps DAX simple (filter a dimension, the fact follows), lets several fact tables share the same slicers, gives time intelligence a gap-free date table, and compresses better because descriptive text is stored once per key rather than on every fact row. I would give a concrete example: in a flat sales table you cannot count customers who bought nothing this month, because they have no rows.

What is the difference between a calculated column and a measure?

A calculated column is computed at refresh for each row and stored, so it costs memory and sees only its own row. A measure is computed at query time in the filter context of each visual cell, so it costs CPU and responds to slicers. Columns are for things you slice or relate by, such as an "is non-product" flag; measures are for numbers you aggregate, such as revenue per customer. A revenue-per-customer calculated column would give one fixed value per row and ignore every slicer.

Power BI refuses to create a one-to-many relationship. What do you check?

Duplicate values on the one side: blanks, case variants (Power BI compares text case-insensitively, Power Query does not), trailing spaces, or the same key with different attributes left after Remove duplicates. I would check the distinct count against the row count in the dimension, upper-case and trim the key in Power Query, and group by the key with an explicit rule.

Practice task

  1. Build Retail Lines, Sales, Customer, Product and Date exactly as above, with load disabled on Retail Lines.

  2. Create the three relationships, mark the date table and set both sort-by columns.

  3. Add the four measures and the Is Non-Product column, and build the page.

  4. Run check_star.py (or your own SQL) and compare every card and chart.

  5. Add a table of the top 10 products by Revenue, then add the Is Non-Product slicer and record how revenue changes when you exclude non-product lines.

Done when: all cards and the monthly chart match the script output, Model view shows three one-to-many single-direction relationships into Sales, and you can explain the PAPER CRAFT row.

Show a solution
  • Excluding non-product lines (slicer set to False) removes postage (77,803.96), manual adjustments (53,779.93), DOTCOM postage (11,906.36), carriage (6,686.00) and bank charges (165.00), so Revenue drops by 150,341.25 to 8,761,066.65.

  • POST is the highest-earning non-product code; on a merchandise report it should never appear in a top 10 products list.

  • PAPER CRAFT, LITTLE BIRDIE: one line of 80,995 units at 2.08 on 9 December 2011, cancelled by the same customer the same day. Report it with a note, and plan to net it out with returns.

Further reading