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.
| order_id | item | amount |
|---|---|---|
| 101 | Desk lamp | 40 |
| 101 | Cable kit | 20 |
| 101 | Order total | 60 |
| 102 | Monitor arm | 90 |
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.
| order_id | item | amount |
|---|---|---|
| 101 | Desk lamp | 40 |
| 101 | Cable kit | 20 |
| 101 | Order total | 60 |
| 102 | Monitor arm | 90 |
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.
- 1Declare 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.
- 2Move 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.
- 3Check every measure against the declaration
At one row per order line,
amountsums correctly and orders are counted withcount(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.
| order_id | item | amount |
|---|---|---|
| 101 | Desk lamp | 40 |
| 101 | Cable kit | 20 |
| 102 | Monitor arm | 90 |
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 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
210against a true150survives 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 withcount(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:
- Is the row an event? Choose a transaction fact.
- Is the row a regular observation of state? Choose a periodic snapshot.
- 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.
- Pick an SCD patternQuick decision
- Price history with SCD2Concept challenge
- Degenerate dimensionsConcept challenge
- Daily balance snapshotsConcept challenge
- Conform customer across factsConcept challenge
- Subscriber health modelOpen scenario
- Fact type: order-line revenueQuick decision
- Fact type: daily inventoryQuick decision
- Fact type: claim lifecycleQuick decision
- Claim lifecycle snapshotConcept challenge
- Inventory: movement vs snapshotConcept challenge
- Order fulfillment snapshotConcept challenge
- Recruiting pipeline lifecycleConcept challenge
- Package workflow snapshotConcept challenge
- Escalation duration measuresConcept challenge
- Out-of-order milestonesConcept challenge
- Clickstream event grainConcept challenge
- Ride-hailing trip grainConcept challenge
- Invoice-line grainConcept challenge
- The Drink Was MissingJudgment tier
- Four Hands, One InvoiceJudgment tier
- Late-arriving dimensionConcept challenge
- Composite key to surrogateConcept challenge
- Product price and category historyConcept challenge
- Avoid SCD2 row explosionConcept challenge
- Demographic mini-dimensionConcept challenge
- SCD strategy per attributeConcept challenge
- Stacked promotionsQuick decision
- Promotions bridgeConcept challenge
- Conform a date dimensionConcept challenge
- Shrunken month dimensionConcept challenge
- Junk dimensionsConcept challenge
- Mini-dimensionsConcept challenge
- Define activation firstQuick decision
- Atomic events vs metricsQuick decision
- Invite-activation grainConcept challenge
- Marketplace activation dropOpen scenario
- Meta messaging engagementConcept challenge
- Meta marketplace listingsConcept challenge
- Meta watch-time grainConcept challenge
- Amazon order warehouseConcept challenge
- Amazon inventory snapshotsConcept challenge
- Amazon seller conformanceConcept challenge
- Netflix viewing eventsConcept challenge
- Netflix A/B exposure grainQuick decision
- Stripe payment attemptsConcept challenge
- Stripe double-entry ledgerQuick decision
- Healthcare encounter snapshotConcept challenge