How to Build a Kimball Star Schema
A Kimball star schema models analytics data as one fact table (the measurements) surrounded by dimension tables (the context), joined on surrogate keys. It's why reporting queries are fast. Here's how to build one.
Why a star schema
Reporting against raw, normalized tables means every dashboard joins many tables and runs slow. A star schema reshapes the data for reads: a central fact table of measurements, surrounded by dimension tables of descriptive context. Queries become a few predictable joins from the fact out to the dimensions - hence "star."
The two table types
- Fact table - the events/measurements, at one consistent grain (e.g. one row per order line). Holds foreign keys to dimensions + numeric measures (quantity, amount).
- Dimension tables - the "who / what / when / where": customers, products, dates. One row per entity, with descriptive attributes you filter and group by.
Surrogate keys
Dimensions get a surrogate key - a synthetic id, independent of the source system's natural key. It decouples the warehouse from source changes and supports slowly-changing dimensions later.
-- dim_customers
SELECT
md5(id::text) AS customer_sk, -- surrogate key
id AS customer_id, -- natural key, kept for lineage
name AS customer_name,
email
FROM {{ source('raw', 'customers') }}
The fact table
Pick the grain first, then join each dimension to get its surrogate key and keep the measures:
-- fct_order_lines (grain: one row per order line)
SELECT
md5(ol.id::text) AS order_line_sk,
c.customer_sk, -- FK to dim_customers
p.product_sk, -- FK to dim_products
d.date_sk, -- FK to dim_date
ol.quantity, -- measure
ol.quantity * p.price AS line_total -- measure
FROM {{ source('raw', 'order_lines') }} ol
JOIN dim_customers c ON c.customer_id = ol.customer_id
JOIN dim_products p ON p.product_id = ol.product_id
JOIN dim_date d ON d.date_day = ol.ordered_on::date
The rules that keep it clean
- One grain per fact table - never mix order-level and line-level rows.
- Facts hold keys + numbers; dimensions hold text you filter/group by.
- A date dimension (one row per day, with month/quarter/weekday) beats raw timestamps for time analysis.
- Star, not snowflake - keep dimensions flat (denormalized) for fast, simple joins unless you have a strong reason to normalize.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Choosing the fact grain and building dimension tables
- Generating surrogate keys and wiring fact FKs to dimensions
- Keeping facts numeric + dimensions descriptive (star, not snowflake)
FAQ
What is a Kimball star schema?
A dimensional model with one central fact table (measurements at a fixed grain) surrounded by dimension tables (descriptive context), joined on surrogate keys - shaped for fast, simple reporting queries.
What's the difference between a fact and a dimension table?
A fact table holds the events/measurements (numeric measures + foreign keys) at one grain; dimension tables hold the descriptive 'who/what/when' you filter and group by, one row per entity.
Why use surrogate keys in a star schema?
A synthetic key decouples the warehouse from source-system natural keys (which can change or collide) and enables slowly-changing-dimension history later. Keep the natural key as an attribute for lineage.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →