How to Design a Kimball Star Schema for Analytics

Dashboards that join six raw tables run slow. A Kimball star schema fixes that by design: one fact table at a single declared grain, surrounded by dimension tables keyed on surrogate IDs. Design it in four steps - grain, dimensions, fact, integrity tests - and prove no orphan keys with a green dbt build.

Data Engineerkimballstar-schemadimensional-modeling

Why dimensional design, not just more SQL

When every dashboard joins the same six normalized tables, queries are slow and each analyst re-derives the same joins slightly differently. A Kimball star schema solves this at the modeling layer: you shape the data once into a central fact table of measurements surrounded by dimension tables of context. Reporting then becomes a few predictable joins radiating out from the fact - the "star" shape.

Designing one is a deliberate four-step sequence, not ad-hoc SQL. Get the order right and the integrity tests fall out for free.

Step 1 - declare the grain

The grain is the single question: what does one row in the fact table mean? Here the grain is one row per order line. Every measure and foreign key must be true at that grain - never mix order-level and line-level rows in one fact table. Declaring the grain first drives every later decision.

Step 2 - design the dimensions with surrogate keys

Each dimension gets a surrogate key - a synthetic id independent of the source system's natural key. md5(natural_key::text) is the classic deterministic surrogate: collision-free at this scale and stable across rebuilds. Keep the natural key as an attribute for lineage.

-- models/marts/dim_customers.sql
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') }}

A date dimension is generated, not sourced - one row per day beats raw timestamps for time analysis. Use generate_series and a YYYYMMDD integer surrogate:

-- models/marts/dim_date.sql
WITH dates AS (
    SELECT generate_series('2024-01-01'::date,
                           '2024-12-31'::date,
                           '1 day'::interval)::date AS d
)
SELECT
    to_char(d, 'YYYYMMDD')::int AS date_sk,
    d                           AS full_date,
    extract(year  FROM d)::int  AS year,
    extract(month FROM d)::int  AS month,
    extract(dow   FROM d)::int  AS day_of_week
FROM dates

Step 3 - build the fact at the declared grain

The fact table holds foreign keys to each dimension plus numeric measures. Derive each FK the same way the dimension derived its surrogate key so they match exactly:

-- models/marts/fct_orders.sql (grain: one row per order line)
SELECT
    md5(ol.id::text)                        AS order_sk,
    md5(ol.customer_id::text)               AS customer_sk,   -- FK
    md5(ol.product_id::text)                AS product_sk,    -- FK
    to_char(ol.order_date, 'YYYYMMDD')::int AS date_sk,       -- FK
    ol.quantity,
    ol.quantity * p.price                   AS gross_amount,
    ol.discount                             AS discount_amount,
    ol.quantity * p.price - ol.discount     AS net_amount
FROM {{ source('raw', 'order_lines') }} ol
JOIN {{ source('raw', 'products') }} p ON p.id = ol.product_id

Step 4 - prove integrity with relationships tests

A star schema is only correct if every FK in the fact resolves to a real dimension row. dbt's built-in relationships test is exactly that referential-integrity check - it fails the build on any orphan key. Declare one per FK in schema.yml, plus unique + not_null on every surrogate key and a grain description on each model:

version: 2
models:
  - name: fct_orders
    description: "Order line fact. Grain: one row per order line."
    columns:
      - name: order_sk
        tests: [not_null, unique]
      - name: customer_sk
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_sk

Then run models and tests together in one command:

cd dbt_project
dbt build --profiles-dir ..

A green dbt build means the schema materialized AND every referential-integrity test passed - no orphan FKs. That exit-0 is the proof the design is sound.

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

FAQ

What is a Kimball star schema?

A Kimball star schema is a dimensional model with one central fact table of measurements at a single declared grain, surrounded by dimension tables of descriptive context, joined on surrogate keys. The layout is optimized for fast, predictable reporting queries.

How do you design a star schema step by step?

Work in four steps: declare the fact grain (what one row means), design dimension tables each with a surrogate key, build the fact table with FKs to the dimensions plus numeric measures at that grain, then add referential-integrity tests. Declaring the grain first drives every later decision.

What is a surrogate key in dimensional modeling?

A surrogate key is a synthetic identifier for a dimension row, independent of the source system's natural key. It decouples the warehouse from source changes and enables slowly-changing-dimension history later; md5(natural_key::text) is a common deterministic choice. Keep the natural key as an attribute for lineage.

How do you test foreign key integrity in a star schema with dbt?

Use dbt's built-in relationships test in schema.yml on each fact foreign key, pointing at the referenced dimension: to ref('dim_customers'), field customer_sk. It fails the build if any FK value has no matching dimension row, so dbt build catches orphan keys automatically.

Keep learning

Build a Kimball Star SchemaData projectBuild a dbt Staging-to-Marts ProjectData projectTrack History with dbt SCD2 SnapshotsData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

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 →