Learn  /  Guides

Star schema and dimensional modelling: a practical introduction

The data model behind fast, trustworthy dashboards. Facts, dimensions and the one decision that matters most: grain.

Guide10 min readUpdated 30 September 2026
Data modellingSQLPower BI
All guides and cheat sheets

A good data model is the difference between a dashboard that is fast and trusted, and one that is slow, confusing and quietly wrong. The star schema, from Ralph Kimball's dimensional modelling approach, remains the most widely used design for analytics, and it is what Power BI's engine is optimised for.

Facts and dimensions

A star schema has one central fact table surrounded by dimension tables.

Fact tables

Events
Record
sales, orders, website sessions, stock movements
Shape
long and narrow
Contain
numbers and foreign keys

What happened, and how much.

Dimension tables

Context
Describe
who, what, where and when
Shape
short and wide
Contain
descriptive attributes you filter and group by

Customers, products, stores and dates.

fct_salesone row per order line dim_date dim_product dim_customer dim_store
Filters flow from the dimensions into the fact table.

Grain: the decision that matters most

The grain is what one row in a fact table represents. Decide it first and write it down: "one row per order line", "one row per product per store per day". Every number in the table must be true at that grain. Most double-counting bugs come from mixing grains, for example storing an order-level delivery charge on every order line.

Building the tables

SQL
-- Dimension: one row per product, with a surrogate key
CREATE TABLE dim_product AS
SELECT
    ROW_NUMBER() OVER (ORDER BY product_code) AS product_key,
    product_code,
    product_name,
    category,
    brand
FROM staging.products;

-- Fact: one row per order line, with keys and numeric measures only
CREATE TABLE fct_sales AS
SELECT
    ol.order_id,
    ol.line_number,
    d.date_key,
    p.product_key,
    c.customer_key,
    ol.quantity,
    ol.unit_price,
    ol.quantity * ol.unit_price AS gross_amount
FROM staging.order_lines ol
JOIN dim_date     d ON d.date = ol.order_date
JOIN dim_product  p ON p.product_code = ol.product_code
JOIN dim_customer c ON c.customer_id = ol.customer_id;

A surrogate key such as product_key is a meaningless integer that belongs to the warehouse, not to the source system. It protects you when source IDs change, collide between systems, or need history tracking.

Always build a date dimension

A date table with one row per day unlocks time intelligence in Power BI and makes "sales by week, month, quarter or financial year" trivial.

SQL
-- Snowflake: generate ten years of dates
CREATE OR REPLACE TABLE dim_date AS
SELECT
    TO_NUMBER(TO_CHAR(d, 'YYYYMMDD')) AS date_key,
    d                                  AS date,
    YEAR(d)                            AS year,
    QUARTER(d)                         AS quarter,
    MONTH(d)                           AS month,
    MONTHNAME(d)                       AS month_name,
    DAYOFWEEKISO(d)                    AS day_of_week,
    IFF(DAYOFWEEKISO(d) IN (6, 7), TRUE, FALSE) AS is_weekend
FROM (
    SELECT DATEADD(day, SEQ4(), '2020-01-01'::DATE) AS d
    FROM TABLE(GENERATOR(ROWCOUNT => 3653))
);

Slowly changing dimensions

Customers move, products change category. How you handle that is called a slowly changing dimension (SCD) strategy.

Type 1

Overwrite
What happens
the old value is replaced
  • Simple, one row per customer
  • History is lost

Use it for corrections, or when history does not matter.

Type 2

New row
What happens
a new row with valid_from, valid_to and is_current
  • Reports reflect the value at the time of the event
  • More rows, and joins must pick the right version

Use it when history matters, such as a customer's region at the time of a sale.

In dbt, snapshots implement Type 2 for you, which is covered in the dbt guide.

Why Power BI loves star schemas

Predictable filters
Filters flow cleanly from one dimension to the fact table, so measures behave predictably.
Speed
The VertiPaq engine compresses narrow fact tables extremely well, keeping reports fast.
Simpler DAX
CALCULATE([Sales], dim_product[category] = "Coffee") just works. See DAX CALCULATE, explained.
Friendly for users
Tables named after things the business understands.

Avoid the temptation to build one giant flat table. It feels simpler at first but grows slowly, duplicates attributes and makes time intelligence awkward.

A checklist for your next model

  1. 1

    Write the grain

    One sentence for every fact table.

  2. 2

    Keep facts numeric and dimensions descriptive

    Numbers in facts, attributes in dimensions.

  3. 3

    Add surrogate keys and an "unknown" row

    So missing matches show clearly instead of vanishing.

  4. 4

    Build a proper date dimension

    It unlocks time intelligence.

  5. 5

    Test keys and relationships

    As described in the data quality guide.

Check your understanding

1/3What is the grain of a fact table?

2/3Where should an order-level delivery charge NOT be stored?

3/3Which slowly changing dimension type keeps history?

AI assistants need the same clarity: see The semantic layer is back.

Alessandro Ecclesie AgazziWritten by Alessandro Ecclesie Agazzi, analytics engineer in London. Updated 30 September 2026. About me

Found this useful?

New guides and "what's new" explainers on data and AI land here regularly. Follow along on LinkedIn, or share this one with a colleague.