DEV Community

Cover image for Kimball Dimensional Modeling, Explained Through a Coffee Shop
Nariman Baubekov
Nariman Baubekov

Posted on

Kimball Dimensional Modeling, Explained Through a Coffee Shop

If you've ever worked near a data warehouse, you've heard the word Kimball. Maybe you nodded. Maybe you quietly googled it and got a 600-page textbook and closed the tab.

Fair. Ralph Kimball's The Data Warehouse Toolkit is a lot. But the actual ideas behind dimensional modeling are small, friendly, and once you see them in a concrete example, they stick. So we're going to learn them through a coffee shop.

Specifically: Bean & Stalk, a fictional cafe with two locations, a loyalty program, a chalkboard menu that changes seasonally, and — importantly for us — surprisingly messy data.

This is Part 1 of a five-part series. Each part is its own self-contained business scenario, but they build on each other — the vocabulary this article establishes (grain, star schema, SCD2) is what every later part assumes you already have:

Part Scenario What it covers
1 — this one Bean & Stalk, a coffee shop Fact vs. dimension tables, grain, star vs. snowflake, SCD Types 0–3
2 Tabby, a SaaS cat-tracker company Subscription grain, account hierarchies, MRR, SCD2 on plan changes
3 Crate Expectations, e-commerce fulfillment Accumulating snapshots that fork, late-arriving facts, semi-additive measures
4 Meadowlark Health, insurance claims Bridge tables, real many-to-many relationships, the weighting-factor trap
5 Back to Tabby, a contract dispute The capstone — a case with no textbook answer, three stakeholders, three defensible numbers

You can read this one on its own; it's the fundamentals every later part leans on. Parts 2 through 5 each stand alone too, but they read best in order — Part 5 in particular assumes you've seen everything before it.

By the end of this article you'll understand:

  • What fact tables and dimension tables actually are (and all their sub-types)
  • How to pick a grain and why it's the most important decision you'll make
  • Star vs. snowflake schemas, and why star almost always wins
  • Slowly Changing Dimensions (SCD Types 0, 1, 2, and 3) without falling asleep
  • How to write queries against a dimensional model that are actually pleasant to read

There's a companion repo with the full schema, seed data, and exercises for every example below. We'll point at it as we go.


Why Kimball still matters in 2026

You might be wondering if this is all dated. We have dbt. We have columnar cloud warehouses. We have the lakehouse. People keep declaring the death of the warehouse. Isn't dimensional modeling a relic?

No. Here's why.

The modern data stack changed where and how we store data, but it didn't change how humans think about business questions. When someone asks "how did the oat-milk latte do in the Pacific Northwest stores last quarter, compared to the same quarter last year?" — they are asking for a fact (sales) sliced by dimensions (product, region, time). That question has not changed since 1996. It will not change in 2036.

What dbt gave us is a cleaner way to build those fact and dimension tables. It didn't replace the shape of the model. If anything, it made dimensional modeling more accessible, because now anyone with SQL can build a star schema in a few hours.

So: Kimball isn't a legacy thing. It's a thinking tool for making data legible to humans. Let's learn it.


The core problem: operational DB ≠ analytical DB

Meet Bean & Stalk. Their point-of-sale system runs on a transactional database. Tables like orders, order_items, products, customers, payments, inventory_adjustments. Beautifully normalized. Great for the cash register.

Now the owner, Priya, wants a dashboard. "Top drinks by month. Year-over-year growth. Which baristas upsell the most food. Loyalty members who've gone quiet."

You could run those queries against the POS database directly. The first time. Maybe the second. By the fifth report you'll discover:

  • The joins are six tables deep.
  • A historical price change means today's $5.25 oat-milk latte and last year's $4.75 oat-milk latte look like two different products unless you're careful.
  • Someone updated a customer's email and now you can't reconstruct what was emailed last quarter.
  • The CEO's "monthly sales" query takes 40 seconds because it's aggregating across years of transaction rows every time.

The operational database is optimized for writing (taking orders fast). The analytical database should be optimized for reading (answering questions fast). Those two workloads want different shapes.

Dimensional modeling is the shape for the analytical side.


Meet Bean & Stalk

Let's set the scene so the schema makes sense.

  • Two stores: Mission St (the original) and Hayes Valley (the new one).
  • Drinks and food: espresso drinks, drip coffee, pastries, beans.
  • Loyalty program: customers sign up, earn stamps, get a free drink after 10.
  • Seasonal menu: the pumpkin spice situation comes and goes.
  • Baristas: a small rotating cast.

Now let's model it.


Fact tables are the verbs

A fact table holds measurable, quantitative events. Things that happened. Each row is typically an event at a point in time, expressed as numeric measurements (called measures or facts) plus foreign keys pointing to surrounding dimensions.

If dimensions are the nouns, facts are the verbs. "We sold 2 oat-milk lattes at Mission St on Tuesday." — that's a verb (sold).

There are several common flavors of fact table. Bean & Stalk will eventually have all of them.

1. Transaction fact table

The workhorse. One row per event at its most granular level. For us: one row per line item on an order.

-- Simplified
CREATE TABLE fact_order_line (
    order_line_sk    BIGSERIAL PRIMARY KEY,
    receipt_number   BIGINT,            -- degenerate dimension
    order_date_sk    INT REFERENCES dim_date(date_sk),
    pickup_date_sk   INT REFERENCES dim_date(date_sk),
    product_sk       INT REFERENCES dim_product(product_sk),
    customer_sk      INT REFERENCES dim_customer(customer_sk),
    store_sk         INT REFERENCES dim_store(store_sk),
    barista_sk       INT REFERENCES dim_barista(barista_sk),
    junk_sk          INT REFERENCES dim_junk(junk_sk),
    quantity         INT,
    unit_price       NUMERIC(8,2),
    discount_amount  NUMERIC(8,2) DEFAULT 0,
    line_total       NUMERIC(8,2)
);
Enter fullscreen mode Exit fullscreen mode

Every order line is a row. Two lattes on one receipt? That's one row with quantity = 2. A latte and a muffin on the same receipt? Two rows. Simple.

2. Periodic snapshot fact table

Some questions want a regular photo of the world rather than a stream of events. A periodic snapshot takes a reading at a fixed interval — daily, weekly, monthly.

For Bean & Stalk: a daily snapshot of sales by drink by store. One row per (date, store, product).

CREATE TABLE fact_daily_sales (
    daily_sales_sk   BIGSERIAL PRIMARY KEY,
    snapshot_date_sk INT REFERENCES dim_date(date_sk),
    store_sk         INT REFERENCES dim_store(store_sk),
    product_sk       INT REFERENCES dim_product(product_sk),
    daily_quantity   INT,
    daily_revenue    NUMERIC(10,2),
    transaction_count INT
);
Enter fullscreen mode Exit fullscreen mode

Why bother when we could just recompute from fact_order_line? Three reasons:

  • Performance — a year-over-year chart on transaction rows scans millions of rows; the snapshot scans thousands.
  • History of truth — if someone deletes an order tomorrow, the snapshot from yesterday still tells you what was reported then.
  • Simplicity — the dashboard query becomes SELECT ... FROM fact_daily_sales instead of a 12-way join.

3. Accumulating snapshot fact table

This one is for multi-stage processes that evolve over time. Think: order placed → shipped → delivered. Or for us: loyalty signup → first purchase → 5th purchase → 10th purchase (free drink earned).

One row per entity (a customer's loyalty journey). Columns for each milestone date. You update the same row as milestones are hit, rather than inserting new ones.

CREATE TABLE fact_loyalty_journey (
    journey_sk                BIGSERIAL PRIMARY KEY,
    customer_sk               INT REFERENCES dim_customer(customer_sk),
    signup_date_sk            INT REFERENCES dim_date(date_sk),
    first_purchase_date_sk    INT REFERENCES dim_date(date_sk),
    fifth_purchase_date_sk    INT REFERENCES dim_date(date_sk),
    tenth_purchase_date_sk    INT REFERENCES dim_date(date_sk),
    current_status            TEXT,        -- 'signed_up', 'active', 'reward_earned', 'churned'
    signup_to_first_days      INT,
    first_to_tenth_days       INT
);
Enter fullscreen mode Exit fullscreen mode

Note this is fundamentally different from a transaction fact (which never updates) or a periodic snapshot (which inserts new rows on a schedule). Accumulating snapshots are updated in place. That's the tell.

4. Factless fact table

Sounds like a Zen koan. It's actually simple: a fact table with no measures, only foreign keys. It exists to record that something was possible or happened without a number attached.

Bean & Stalk example: drink availability. The pumpkin spice latte is available on certain dates and not others. That "this drink was offered on this day in this store" is a fact worth recording, even though it has no quantity.

CREATE TABLE fact_drink_availability (
    drink_availability_sk BIGSERIAL PRIMARY KEY,
    product_sk            INT REFERENCES dim_product(product_sk),
    date_sk               INT REFERENCES dim_date(date_sk),
    store_sk               INT REFERENCES dim_store(store_sk)
    -- no measures! the presence of the row IS the fact
);
Enter fullscreen mode Exit fullscreen mode

Now "how many days was the PSL available at Mission St in 2025?" is a trivial COUNT(*).

Quick mental model

Type When to use Bean & Stalk example
Transaction Atomic events Each line on a receipt
Periodic snapshot Regular photos Daily sales by store/product
Accumulating snapshot Multi-stage journeys Loyalty signup → 10th drink
Factless Coverage / eligibility Drink available on a day

Dimension tables are the nouns

A dimension table holds the descriptive context around the facts — the who, what, where, when, how. Dimensions are how you slice and filter. They tend to be wide (many columns) and short (far fewer rows than fact tables).

Let's meet each kind through Bean & Stalk.

Date dimension (the universal one)

Every dimensional model needs a dim_date. Yes, even in 2026. Yes, even though you could compute EXTRACT(MONTH FROM date) on the fly.

Two reasons. First, business calendar logic (fiscal quarters, holidays, "is this a weekend") is awful to compute and trivial to store. Second, joining on an integer date_sk is faster and more compression-friendly than joining on a timestamp.

CREATE TABLE dim_date (
    date_sk       INT PRIMARY KEY,        -- e.g. 20260813 for 2026-08-13
    full_date     DATE NOT NULL,
    day_of_week   TEXT,                    -- 'Monday'
    day_number    INT,
    month_number  INT,
    month_name    TEXT,
    quarter       INT,
    year          INT,
    is_weekend    BOOLEAN,
    holiday_name  TEXT
);
Enter fullscreen mode Exit fullscreen mode

The classic trick: role-playing dimensions. Bean & Stalk has both an order date and a pickup date (mobile orders). Same dim_date table, two foreign keys in the fact. In the query, you join dim_date twice with different aliases (order_date and pickup_date).

Product dimension with Slowly Changing Dimensions

Here's where it gets interesting. The dim_product looks like a normal dimension:

CREATE TABLE dim_product (
    product_sk       BIGSERIAL PRIMARY KEY,
    product_id       TEXT,                -- natural key, e.g. 'OAT_LATTE'
    product_name     TEXT,
    category         TEXT,                 -- 'ESPRESSO_DRINK', 'DRIPO', 'PASTRY'
    base_price       NUMERIC(8,2),
    recipe_notes     TEXT,
    -- SCD Type 2 columns:
    valid_from       DATE NOT NULL,
    valid_to         DATE,
    is_current       BOOLEAN NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

But Bean & Stalk changes things over time. The oat-milk latte's recipe changed in March (new oat milk vendor). Its price went up in June. How do you model that?

This is the SCD problem. Slowly Changing Dimensions. There are four common strategies. Let's walk through them with the same example.

SCD Type 0 — retain original. Never change the value. The original row is the row. Useful for things that should be immutable, like the date a customer signed up. Bean & Stalk treats product_id this way.

SCD Type 1 — overwrite. Just update the row. Old value is lost. Use this when history doesn't matter. For a typo fix in a product description (Oat Milk LatteOat-Milk Latte), Type 1 is fine.

SCD Type 2 — add a new row. Insert a new row with the new values, expire the old row by setting valid_to and is_current = false. This preserves full history. For price and recipe changes, Bean & Stalk uses Type 2.

A Type 2 product history looks like this:

product_sk product_id product_name base_price valid_from valid_to is_current
1 OAT_LATTE Oat-Milk Latte 4.75 2025-01-01 2025-06-14 false
2 OAT_LATTE Oat-Milk Latte 5.25 2025-06-15 NULL true

Now your historical reports use the price that was actually charged at the time, not today's price. This is the whole point.

Laid out on a timeline, the two rows look like this — the fact table always points at whichever product_sk was valid on the day the sale happened, so a March query and an August query silently pick up different rows without needing any date logic of their own:

SCD Type 2 timeline: two product_sk rows covering non-overlapping date ranges, with fact rows pointing at whichever was valid on the sale date

SCD Type 3 — add a column. Keep the old value in a separate column (previous_base_price, previous_valid_until). Useful when you only care about the previous state, not the full history. Bean & Stalk doesn't bother with Type 3 for products — Type 2 is strictly more powerful — but you'll see it in domains where people genuinely only care about "before vs after" (a plan's previous tier, an employee's previous role).

The takeaway: Type 1 and Type 2 cover ~95% of real-world cases. Don't reach for the others unless you have a specific reason.

Customer dimension

Standard Type 1-ish dimension for Bean & Stalk's loyalty members.

CREATE TABLE dim_customer (
    customer_sk    BIGSERIAL PRIMARY KEY,
    customer_id    TEXT,
    name           TEXT,
    email          TEXT,
    loyalty_number TEXT,
    signup_date    DATE,
    loyalty_tier   TEXT                -- 'BRONZE', 'SILVER', 'GOLD'
);
Enter fullscreen mode Exit fullscreen mode

If we cared about tracking tier changes over time (Bronze → Silver → Gold), we'd make this Type 2 too.

Store and barista dimensions

dim_store is a small, stable dimension. dim_barista holds employee attributes. Both Type 1.

Junk dimension

This is a fun one. Bean & Stalk has several low-cardinality flags: size (small/medium/large), milk_type (whole/oat/almond/soy/none), syrup_flavor (none/vanilla/caramel/hazelnut), extra_shot (true/false). None of these deserve their own dimension. But putting each one as a column directly on the fact table is fine too — except it clutters things.

A junk dimension combines them into one small table of all observed combinations:

CREATE TABLE dim_junk (
    junk_sk       BIGSERIAL PRIMARY KEY,
    size          TEXT,             -- SMALL / MEDIUM / LARGE
    milk_type     TEXT,             -- WHOLE / OAT / ALMOND / SOY / NONE
    syrup_flavor  TEXT,             -- NONE / VANILLA / CARAMEL / HAZELNUT
    extra_shot    BOOLEAN
);
Enter fullscreen mode Exit fullscreen mode

The fact table holds a single junk_sk foreign key. Now all those flags live in one tidy place and you can still slice by them.

Degenerate dimension

A degenerate dimension is a dimension key that has no dimension table — it just lives in the fact. The classic case is a receipt number or order number. There's no dim_receipt; the receipt_number column sits directly on fact_order_line so you can group all the lines of one receipt back together. Simple, useful, slightly weird name.


Choosing the grain

If you take one thing from this article, take this: pick your grain first, before anything else.

The grain is the precise definition of what one row in a fact table represents. Until you can state the grain in one plain sentence, you're not ready to build the table.

For Bean & Stalk's transaction fact, three plausible grains:

  1. One row per order. Pros: tiny table. Cons: can't analyze individual drinks within an order. The oat-lattes-plus-muffin combo question becomes impossible.
  2. One row per order line. Pros: fully flexible — you can always roll up to order level with SUM(...) GROUP BY receipt_number. Cons: bigger table. (This is what we chose.)
  3. One row per drink modification. Pros: maximally detailed. Cons: enormous table, and "how many lattes did we sell?" requires careful un-nesting. Overkill for a cafe.

The right grain is the most granular level at which a business event occurs that you'd want to analyze separately. For Bean & Stalk that's the order line. Lower than that (sub-line modifications) is overkill; higher than that (whole order) loses detail.

Same logic applies everywhere: an e-commerce fact is usually one row per order line; a payments fact might be one row per transaction; a web analytics fact might be one row per page view. State the grain out loud. If it sounds weird, reconsider.


Star vs. snowflake

You have two layout choices for a dimensional model.

Star schema: the fact table sits in the middle, dimensions radiate out, and each dimension is a flat, denormalized table. One hop from fact to any dimension.

Snowflake schema: dimensions are themselves normalized. dim_product references dim_category, which references dim_department. Three hops to get to department.

Star schema: fact_order_line in the center with dim_date, dim_product, dim_customer, dim_store, dim_barista, and dim_junk radiating out one hop away, versus snowflake: dim_product further normalized into dim_category and dim_department

Snowflake looks cleaner to a normalized-DB brain. It uses less storage. In the 90s, when storage was expensive, that mattered.

In 2026, use star. Almost always. Reasons:

  • Query simplicity. One join, not three. Your dashboard authors will thank you.
  • Columnar warehouses love wide, flat dimensions. Compression is excellent. Joins are cheap. Snowflake normalization actively hurts you here.
  • Human legibility. A star schema can be read and understood by an analyst in 30 seconds. A snowflake requires tracing keys around.
  • Storage is cheap. The thing snowflake optimized for is no longer scarce.

Normalize your operational database. Denormalize your analytical one. That's the whole game.


A real query

Let's put it together. Top-selling drinks by month, with a barista leaderboard for the top drink.

-- Top drinks by month
SELECT
    d.month_name,
    p.product_name,
    SUM(f.quantity)         AS total_sold,
    SUM(f.line_total)       AS revenue
FROM fact_order_line f
JOIN dim_date   d ON d.date_sk = f.order_date_sk
JOIN dim_product p ON p.product_sk = f.product_sk
WHERE d.year = 2025
GROUP BY d.month_name, p.product_name
ORDER BY d.month_number, revenue DESC;
Enter fullscreen mode Exit fullscreen mode

That's the whole query. Notice how readable it is. Five joins, all one-hop, clear aliases. That's the star schema paying off.

Now the barista leaderboard for the top drink of the year:

WITH top_drink AS (
    SELECT p.product_sk, p.product_name
    FROM fact_order_line f
    JOIN dim_product p ON p.product_sk = f.product_sk
    GROUP BY p.product_sk, p.product_name
    ORDER BY SUM(f.line_total) DESC
    LIMIT 1
)
SELECT
    b.barista_name,
    SUM(f.quantity)   AS drinks_made,
    SUM(f.line_total) AS revenue
FROM fact_order_line f
JOIN dim_barista b ON b.barista_sk = f.barista_sk
JOIN top_drink    t ON t.product_sk = f.product_sk
GROUP BY b.barista_name
ORDER BY revenue DESC
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

Try writing that against the normalized POS schema. It's possible, but it'll take three times the SQL and ten times the thinking.

The full version (with role-playing date dimensions, SCD2-aware joins, and the "as-of" lookup pattern) is in queries.sql in the companion repo.


Common mistakes

These are common. Avoid them.

  1. Duplicate facts in different tables. If line_total lives in fact_order_line, don't also store a precomputed daily_total next to it in a different fact. Compute it at query time, or build a proper snapshot fact. Two sources of the same number always drift.

  2. Storing aggregates next to base rows. Adding a monthly_total column to fact_order_line is a Category 5 anti-pattern. It will be wrong by the second week.

  3. Forgetting SCD2 on things that change. If your product price changed and you overwrote the row, every historical report is now silently incorrect. Use Type 2.

  4. Putting measures in dimension tables. A "current price" column on dim_product is fine. A "total units sold last quarter" column on dim_product is a trap — it'll be stale and it muddies the dimension's purpose.

  5. Mixing grains in one fact table. One row per order line and one row per daily summary in the same table? No. Either split into two facts or pick the lower grain.

  6. Using natural keys as surrogate keys. product_id is great as a natural key but don't make it the primary key of dim_product — Type 2 means you'll have multiple rows per product_id. Always use a surrogate product_sk.


Exercises

A few quick ones to test your understanding. Hints are hidden — click to expand. Full solutions and more practice are in exercises.sql in the companion repo.

1. Bean & Stalk wants to track which baristas work which shifts. Would you model "shift" as its own fact table, a dimension, or an attribute on the barista? Why?

Hint
Think about whether a shift is an event (verb → fact) or a descriptor (noun → dimension). Could it be both? What questions would each shape let you answer?

2. Write a query that uses role-playing date dimensions to find orders where the pickup date was a different day than the order date (i.e., mobile pre-orders for tomorrow).

Hint
Join dim_date twice with different aliases, once on order_date_sk and once on pickup_date_sk. Then filter where the two full_date values differ.

3. The oat-milk latte's price changed on 2025-06-15. Using the SCD2 product dimension, write a query to compute total revenue for the oat-milk latte where each transaction uses the price that was actually in effect at the time.

Hint
The fact table's product_sk already points at the correct historical row of dim_product (that's the SCD2 magic). You don't need any extra date filtering on the dimension — just sum line_total.

4. Bean & Stalk launches a "drink of the week" promotion. How would you model that using a factless fact table?

Hint
Each row: (product_sk, date_sk, store_sk). No measures. The row's existence is the fact "this drink was promoted on this day at this store."


What's next

This article covered the conceptual fundamentals — the things that show up in every dimensional model. From here, the series gets progressively less textbook and more "here's what actually goes wrong in practice":

  • Part 2 applies these fundamentals to SaaS subscriptions — MRR reconstructed as-of arbitrary dates, account hierarchies, and SCD2 doing real work on plan changes.
  • Part 3 covers accumulating snapshots that don't behave — orders that split into multiple shipments, carrier webhooks that arrive out of order, and a measure that genuinely can't be summed across days.
  • Part 4 is bridge tables — a fact that's legitimately about more than one dimension value at once, and the trap of inventing money the moment you join through one carelessly.
  • Part 5 is the capstone: a scenario with no textbook answer, where three stakeholders each have a defensible number for the same deal, and the job is building a model honest enough to tell the truth to all three at once.

Same ideas throughout, meaner problems each time. Start with Part 2 whenever you're ready.


Resources


If this was useful, the companion repo has the full schema, seed data, and a quiz that tests all of this. Parts 2 through 5 apply everything here to domains that break it in progressively more interesting ways. See you there.

Top comments (0)