Skip to content
Learn/Dimensional modeling

Dimensional modeling

Learn each concept by watching it work, then put it into practice.

On this page

Fact table grain

Declare what one row of a fact table represents, and keep every measure true to it.

7 min read

Overview

Two analysts ask the same sales table for total revenue and come back with different numbers. Neither wrote a wrong query. The disagreement is baked into the table itself, and the rows below are where it hides.

sales
order_iditemamount
101Desk lamp40
101Cable kit20
101Order total60
102Monitor arm90

The first two rows are single items from order 101, while the third is that order’s combined total. One table is describing two different kinds of thing. A fact table records the measurable events of a business and is the table you sum for revenue, an idea fact vs dimension covers in full later in this track.

The kind of thing one row stands for is called the grain. One row per order line means each row is a single item sold. One row per order means each row is a whole order. Every later decision in a fact table rests on which one you pick.

What does one row of this table represent?

The failure

The demo below runs the most ordinary query a fact table receives, a request for total revenue. The business took 150 here, since order 101 sold 60 of goods and order 102 sold 90. Before you run the sum, add up the amount column yourself and predict whether the query will report the money the business actually took.

Declare the grain and watch the total correctThe table holds four rows from two feeds. Run the sum when you have your prediction.
sales
order_iditemamount
101Desk lamp40
101Cable kit20
101Order total60
102Monitor arm90
sum(amount)
?

The query did nothing wrong. It read every row at face value and summed them, exactly as written, and it returned 210 against real revenue of 150. Order 101 entered the total twice, once through its two line rows and once through the order-total row that arrived from the second feed.

A mixed grain fails quietly because each row is individually true. The lines are real and the total is real, so no constraint can object, and every aggregate that touches the table inherits the double-count.

You can reproduce the failure with the sum itself.

select sum(amount)::int as total
from sales;

The inflated number looks plausible, and a plausible wrong number survives review. No error exists anywhere in the pipeline, so the discrepancy is usually found by a person reconciling reports long after the figure has been repeated.

The rule

Look at which rows caused the damage. The two line rows and the total row each describe order 101 truthfully, and the failure appeared only when one table held both descriptions, because a sum cannot tell which rows share meaning. A fact table therefore needs a single declared answer to the question this guide opened with, before any measure is added to it.

Dimensional modeling calls that answer the grain declaration. Ralph Kimball, whose design method shaped most modern warehouses, made declaring the grain the first step of building any fact table. The declaration comes ahead of choosing dimensions or measures, because every later choice is checked against it, and a measure that is not true at the declared grain has no honest place in the table.

The method

Repairing or designing a fact table follows the declaration through three steps.

  1. 1
    Declare the grain in one sentence

    Write the sentence "one row of this table is one order line" and treat it as part of the schema. A grain that cannot be stated in one sentence is usually two tables sharing a name.

  2. 2
    Move the wrong-grain rows out

    The order-total row belongs in a different table or nowhere, since it can be computed from the lines. When a feed delivers totals, land them separately instead of inside the line-grain table.

  3. 3
    Check every measure against the declaration

    At one row per order line, amount sums correctly and orders are counted with count(distinct order_id). A measure that only makes sense for a whole order, such as a shipping fee, either moves to an order-grain table or is allocated across the lines.

sales, one row per order line
order_iditemamount
101Desk lamp40
101Cable kit20
102Monitor arm90

The repaired table gives the opening question a single answer, and the sum from the failure section returns 150 on it. Grain repairs are cheap at design time and expensive afterward, because every downstream report inherits whatever the table declares.

The judgment

Declaring a grain forces a choice about how fine the rows should be. One row per order line is finer than one row per order, which is finer in turn than one row per day. From line-grain rows you can still compute order totals and daily totals, while a table stored at daily grain can never answer which item sold, because aggregation destroys the detail it rolls up.

The cost of fine grain is volume, since one row per order line can be many times the rows of one row per order. Kimball’s advice remains the default for exactly this trade: store the finest grain the source supports and aggregate on the way out, because storage is usually cheaper than a question you can no longer ask.

Some questions genuinely live at a coarser grain, such as an account balance measured at the end of each day. Those are served by dedicated tables built for that purpose, and they arrive later in this track as snapshot fact types.

Pitfalls

The union that mixes grains

The commonest source of a mixed table is a union of two feeds that almost match, such as line exports from one system and order exports from another. Every row loads cleanly and every value is individually true, so the corruption only becomes visible in the aggregates. When two feeds describe different grains, they belong in two tables no matter how similar their columns look.

  • A plausible total hides the damage. A double-counted sum is rarely absurd, and 210 against a true 150 survives a glance. Reconcile a fact table against its source of truth at load time, rather than trusting review to notice.
  • Order-level values on line rows multiply. A shipping fee copied onto every line of its order is summed once per line. Allocate such values across the lines or keep them in an order-grain table.
  • Counting orders from line grain needs distinct. At one row per order line, count(*) counts lines. Orders are counted with count(distinct order_id), and the difference stays invisible until an order has two lines.
  • Averages change meaning with the grain. The average of line amounts differs from the average order value. Decide which average the question asks for, and compute order totals first when it asks for the order one.

Practice

Recap

  • Declare the grain as “one row per ___” before choosing columns, using the finest event the source can provide reliably.
  • Check that every measure belongs to that row and that no table mixes order-level or line-level facts.
  • When totals grow after a join, compare row counts and keys because mixed grain and fan-out are early suspects.
  • Aggregate upward at query time and use count(distinct ...) for entities above the grain.
  • Separate the facts when a feed describes two grains instead of forcing both into one table.

Grain and fact types

Grain is the first promise a fact table makes. Before choosing columns, state what one row means. A row can be one event, one periodic observation, or one workflow instance that accumulates milestone dates over time.

Transaction facts

A transaction fact records an event that happened at a point in time. Use it when the business asks about activity, volume, revenue, or counts of atomic events.

Good fit:

  • one order line
  • one invoice line
  • one page view
  • one shipment scan

Common mistake: summarizing too early. If product managers need the event sequence, a session summary cannot replace the event fact.

Periodic snapshot facts

A periodic snapshot fact records state at a regular interval. Use it when the business asks what the balance, inventory, capacity, or population looked like at the end of each day, week, or month.

Good fit:

  • daily account balance
  • daily inventory on hand
  • monthly active subscribers
  • weekly warehouse capacity

Common mistake: storing only current state on a dimension. Current state cannot recreate historical end-of-day balances after values change.

Accumulating snapshot facts

An accumulating snapshot fact records one workflow instance and updates milestone columns as the workflow moves forward. Use it when the business asks how long work spends between stages.

Good fit:

  • one insurance claim from opened to closed
  • one order from placed to delivered
  • one support ticket from opened to resolved
  • one loan application from submitted to funded

Common mistake: splitting every milestone into disconnected facts before the workflow requires independent event analysis. If the primary question is elapsed time through a pipeline, one accumulating snapshot keeps the path visible.

The choice

Ask three questions:

  1. Is the row an event? Choose a transaction fact.
  2. Is the row a regular observation of state? Choose a periodic snapshot.
  3. Is the row a workflow instance with milestones? Choose an accumulating snapshot.

Kimball fact-table guidance is the pattern source for these choices. dbt semantic modeling reinforces the same habit from the metrics side: metrics need entities, dimensions, measures, and a grain that makes the calculation unambiguous.

Metrics-first product modeling

Product modeling starts with the question the business wants answered. Before drawing tables, define the metric, the time window, the actors, and the row grain that makes the metric computable.

Define the metric

A metric definition should name:

  • numerator
  • denominator
  • time window
  • event or state that qualifies
  • breakdown dimensions
  • exclusions and edge cases

If those pieces are vague, the schema will encode guesses. "Activation" could mean first invite sent, first invite accepted, first workspace created, or first successful collaboration. Each answer creates a different fact grain.

Choose grain from the metric

After the metric is clear, choose the lowest practical grain that supports it. If the team needs activation rate by acquisition channel and workspace plan, an atomic invite or workspace event fact can support the rate and later debugging. A user-day aggregate may be fast, but it cannot recover event sequence or changed metric definitions.

Explain tradeoffs

Interview-style prompts are not only looking for a schema. A strong answer clarifies assumptions, states grain, shows how the metric is computed, names history decisions, explains alternatives, and calls out operational concerns such as late events, backfills, bot traffic, and data quality.

Kimball transaction fact guidance is the pattern source for atomic product events. dbt semantic modeling reinforces the metrics side: entities, dimensions, measures, and time windows must line up before a metric is trustworthy.

Practice this concept

Challenges that apply what this lesson covers.

SCD Type 2

Version the dimension row instead of updating it, so a report about the past cannot be rewritten by the present.

8 min read

Overview

Keeping a warehouse current sounds routine. dim_courier describes a small delivery company’s couriers, while fact_deliveries records one row per June delivery and references the courier who made it. The data below is enough to build the report.

dim_courier
courier_idnamehome_depot
1PriyaNorthside
2OmarRiverside
fact_deliveries, June
delivery_idcourier_iddelivered_on
90112026-06-03
90212026-06-11
90312026-06-24
90422026-06-08
90522026-06-19

These tables divide work along fact and dimension roles. The fact stores events, while the dimension supplies the descriptive context used to read them. A depot report joins each delivery to its courier and groups by home_depot.

On July 1, Priya transfers from Northside to Riverside. The operational system naturally updates her single row because dispatch needs her current depot, and the schema accepts that edit. June’s depot report has already been run and filed.

Nothing looks wrong when the update lands. The next section reruns the same June report before and after it, exposing what the current-state edit changes.

The failure

The demo runs June’s depot report twice: before Priya’s transfer and after it. Before pressing play, predict the second result. All five deliveries happened in June, but the dimension will describe July. Watch Priya’s row change and compare the rerun with your answer.

Watch a July update rewrite JuneEach courier holds one row, and all five June deliveries reference these rows by courier_id.
the warehouse as it stands
dim_courier
courier_idnamehome_depot
1PriyaNorthside
2OmarRiverside
history arrives as new rows

The first run returns Northside 3 and Riverside 2. The update then replaces the only depot value Priya has. The same join reads her three June deliveries through that new row, so the rerun returns Riverside 5. Northside disappears from a month it actually served.

No error fires. The update did exactly what dispatch needed, and the report query is unchanged and valid. Its answer changes because a question about June is joined to whatever the dimension says today. With no row carrying Priya’s earlier depot, the warehouse can no longer reconstruct the original result.

Running the report against the updated tables reproduces the rewrite:

select d.home_depot as depot, count(*)::int as deliveries
from fact_deliveries f
join dim_courier d on f.courier_id = d.courier_id
group by d.home_depot
order by d.home_depot;

The single result, Riverside with 5, contains a correct total that can survive a quick review. What it loses is the location of the work, the only fact this depot report exists to explain.

The rule

The update destroyed a fact the business still needs. home_depot was never just one value: Priya worked from Northside through June and Riverside from July onward. Each value carried a period that one overwritable cell could not hold, so recording the new truth erased the old one.

That overwrite is correct in the operational dispatch system, which needs exactly the current roster. Normalization says to store a current fact once. The warehouse has a different job because its questions reach into the past, so current-state design alone cannot answer them faithfully.

Ralph Kimball’s dimensional method calls these attributes slowly changing dimensions, which gives this guide its SCD abbreviation. The method numbers the standard treatments. Type 1 is the overwrite you watched and is legitimate when no one will need the old value. Type 2 preserves the old value, and its rule is short:

When a tracked value changes, close the old row and add a new one.

Adding a row keeps both truths and binds each to the period it was valid. The dimension can now answer questions about then and now. The price is several rows per courier, which breaks the one-row-per-thing assumption. The method below makes those versions safe to use.

The method

Type 2 uses three moves, each solving a problem created by multiple versions.

  1. 1
    Give every version its own key

    Two rows now describe courier 1, so courier_id cannot remain the primary key. Give each version a surrogate key such as courier_key. The key has no business meaning. Keep courier_id as the natural key that identifies the real courier across versions.

  2. 2
    Bound every version with validity dates

    A pair of validity columns records when each version held. effective_from marks its start, and effective_to marks when the successor begins. The current version leaves effective_to as null because its end is unknown. An is_current flag exposes that open row without a date comparison.

  3. 3
    Stamp each fact with the version in force

    When a delivery loads, find the version whose window contains its delivery date and stamp that version’s courier_key onto the fact. Later joins use plain key equality. Because the stamped key never changes, a future transfer cannot move a June delivery to a depot it never left from.

dim_courier_versions
courier_keycourier_idhome_depoteffective_fromeffective_tois_current
11Northside2026-01-152026-07-01false
21Riverside2026-07-01nulltrue
32Riverside2026-03-02nulltrue

Priya now has two versions under one natural key. July 1 is the seam where one window closes and the next opens. The failed report changes in only one place: it joins through the stamped key.

select v.home_depot as depot, count(*)::int as deliveries
from fact_deliveries f
join dim_courier_versions v on f.courier_key = v.courier_key
group by v.home_depot
order by v.home_depot;

June’s three Northside deliveries retain courier_key 1, so they always resolve to the Northside version. The report remains Northside 3 and Riverside 2 after later transfers. Filtering is_current returns today’s roster from the same dimension.

The judgment

Type 2 preserves history and charges for it. Every change adds a row, every consumer must understand that one courier can have several versions, and the load must detect changes and close windows correctly. Even count(*) needs care because it counts versions rather than couriers.

Ask whether anyone will need an attribute’s value as of an event date. A depot report asks exactly that of home_depot, so it earns Type 2. A courier’s phone number rarely does, so Type 1 is usually enough. Make the choice per attribute because one dimension can carry both treatments.

When the business needs every entity’s state at every period end, the question changes shape and calls for snapshot fact types. Type 2 remains the fit for history recorded when occasional changes occur rather than on a schedule.

Pitfalls

The join that pays twice

A half-adopted Type 2 inflates instead of rewriting. If a fact keeps courier_id and joins the versioned dimension on it, each delivery matches every version of its courier. Priya’s June rows double after her second version appears. Stamp courier_key so every fact reaches exactly one version.

  • Counting versions as couriers. Once one courier can occupy several rows, count(*) answers how many versions exist. Count distinct courier_id, or filter is_current when the question is the present roster.
  • Windows that overlap or gap. Use contiguous half-open windows where each effective_to equals the next effective_from. An overlap returns two versions for one date, while a gap returns none. Close the old row and open the new one in the same load step.
  • A stale current flag. is_current repeats what an open window already says and can drift after a failed load. Derive or verify it from validity columns instead of trusting the flag alone.
  • Versioning a value that never sits still. Type 2 assumes occasional change. A daily-changing value creates excessive versions, while a value that changes with every delivery is a measure and belongs on the fact table.

Practice

Recap

  • Use Type 2 when a report must recover an attribute as of an event date. Use Type 1 when only the current value matters.
  • Close the old row and open the new row, then stamp each fact with the version key valid on its event date.
  • Audit for one current row per natural key, no window gaps or overlaps, and exactly one version match per fact.
  • Snapshot facts fit when the business asks for every entity’s state at every period instead of occasional change history.

SCD and history modeling

Slowly changing dimension choices decide whether analysis sees the world as it looks today or as it looked when the business event happened. Before choosing a type, ask which attribute changed, who will query the old value, and how much history the business actually needs.

Type 1 overwrite

Use Type 1 when only the current value matters. The old value is replaced in place.

Good fit:

  • correcting a misspelled employee name
  • storing the current office phone extension
  • keeping a current-only employee directory

Common mistake: using Type 2 because "history is always safer." Type 2 adds rows, joins, and operational cost. If the product never analyzes prior values, that history can be waste.

Type 2 row versioning

Use Type 2 when historical reporting must use the attribute value that was valid when a fact happened. A stable natural key identifies the business object, while a surrogate key identifies each version row.

Good fit:

  • product price and category history
  • customer tier at purchase time
  • seller region at listing time

A strong Type 2 dimension has a surrogate primary key, the natural business identifier as an attribute, effective-from and effective-to timestamps, and usually a current-row flag for operational queries.

Type 3 previous value

Use Type 3 when the business only needs one prior value, not unlimited history. The prior value is stored as another attribute on the current row.

Good fit:

  • current sales territory plus previous sales territory
  • current account manager plus immediately prior account manager

Common mistake: building Type 2 when the question only asks for before/after comparison across one realignment.

Volatile attributes and mini-dimensions

Some attributes change so often that Type 2 would create noisy row churn. Move those volatile values into a fact, snapshot, or mini-dimension instead of versioning the whole customer row every time.

Good fit:

  • loyalty point balance changes
  • behavior buckets
  • demographic or risk segments that update frequently

Put history at the grain where it belongs. Versioning the whole customer row for one volatile attribute puts it in the wrong place.

The choice

Ask four questions:

  1. Does only current state matter? Choose Type 1.
  2. Must facts join to the value that was valid at event time? Choose Type 2.
  3. Is only one prior value needed? Choose Type 3.
  4. Would Type 2 create row explosion for one volatile attribute? Split that attribute into a fact, snapshot, or mini-dimension.

Kimball slowly changing dimension guidance is the pattern source for Type 1, Type 2, and Type 3 tradeoffs. Kimball dimensional modeling techniques also anchor mini-dimension and history split patterns when one volatile attribute would make the base dimension too noisy.