Warehouse Design Guide

Choosing fact table grain: the one sentence that decides the model

By the engineer who builds Datadef, from client work on data platforms · Reviewed August 21, 2026

Grain is the answer to what does one row of this table represent, and it is the decision every other modeling choice depends on. Dimensions attach at the grain. Measures are valid at the grain. Aggregations are safe only if they respect the grain. Teams that skip writing it down do not avoid the decision, they take it implicitly in the first select statement and discover it months later when two reports disagree.

7 min readFor analytics engineers about to write the first fact table for a process

See it as a diagram

Everything below, as a diagram you can edit. Describe yours and see it in seconds.

146/20003 credits left
Try:

No account needed · Editable canvas, not a picture

Write the grain as a sentence, before any SQL

A grain statement is a sentence that starts with one row per. One row per order line. One row per completed shipment. One row per subscription per calendar month. One row per support ticket state change. The test of a good grain sentence is that a business person can read it and say whether it matches how they count things.

Vague grain statements are the ones that cause damage, because they sound decided. One row per order is ambiguous the moment an order can be partially shipped or partially refunded. One row per customer is not a grain at all for a fact table, it is a dimension. If the sentence needs a paragraph of clarification, the grain has not been chosen yet.

Put the sentence in the model file and enforce it with a uniqueness test on the columns that define it. That turns the grain from a convention into something CI can defend on the day somebody adds a row at a different level of detail.

# models/marts/sales/_models.yml
models:
  - name: fct_order_lines
    description: "Grain: one row per order line, per shipment event."
    tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - order_line_id
            - shipment_event_id
    columns:
      - name: net_amount
        description: "Additive at the declared grain."
      - name: order_total_amount
        description: "NOT additive here: repeats per line. Use fct_orders."

Finest available versus finest useful

The default advice is to model at the atomic grain, the finest level the source records. Atomic grain is future-proof in a way aggregates are not: any question that can be answered from a summary can be answered from the atoms, and questions nobody thought of at design time are exactly the ones that arrive later. Aggregating is always possible, disaggregating never is.

The exception is volume that outruns the value. Raw clickstream at one row per event is atomic and is also the reason many warehouses have a table nobody can query without a warning. When the atomic grain is genuinely unaffordable, keep the atoms in the lake or in a lower layer and build the fact table at the finest grain the business actually slices, then document that the atoms exist elsewhere and where to find them.

Building several fact tables at different grains for the same process is normal and correct: an atomic transaction fact, a daily periodic snapshot, and possibly a monthly aggregate. What is not correct is mixing grains inside one table. A table that holds both order lines and order totals will double count on any join, and the person who discovers it will not be you.

Symptoms of the wrong grain

Every measure needs a distinct. If your sum queries look like sum of distinct amounts, rows are repeating for a reason the grain does not account for, usually because a dimension attaches at a finer level than the fact.

Totals inflate when a dimension is added to a report. Adding a product filter changes the revenue total, which means the fact rows fan out across product and the declared grain was coarser than the data.

Half the columns are null for half the rows. Two different events have been packed into one table because they share a source system. Split them, or add the event type as part of the grain sentence and accept the nulls explicitly.

The business asks a question the table cannot answer without going back to the source. That is the aggregation trap and it costs a rebuild plus a backfill. It is also the strongest argument for the atomic grain.

Three fact table types, three grain sentences

Transaction facts record something that happened. Grain: one row per event. They are the most common, they are sparse, and their measures are usually fully additive.

Periodic snapshot facts record a state at regular intervals. Grain: one row per entity per period, for example one row per account per day. They are dense by construction, and their measures are typically semi-additive, meaning a balance can be summed across accounts but not across days.

Accumulating snapshot facts track a process with a known set of milestones. Grain: one row per process instance, for example one row per order, with the row updated as it moves through the milestones. They are the only fact type that is regularly updated rather than appended, which matters for how they are stored and reprocessed.

Labelling each fact node with both its type and its grain sentence is the cheapest documentation in the whole model. On the Datadef canvas that sentence is the node description, so a modeling review starts from what the rows mean rather than from a column list, and column-level lineage traces a measure back to the source column that produced it. validate_canvas will tell you when you skipped it: it raises a warning once more than half the nodes on a canvas have no description, and another for any table node drawn with no columns at all, on the grounds that columns are the point of a table node.

FAQ

What does grain mean in a fact table?

Grain is what a single row of the fact table represents, stated as a sentence beginning with one row per. It determines which dimensions can attach to the table and which measures are valid in it, so it is the first modeling decision and the one every later decision depends on.

Should the grain always be the finest level available?

Usually yes, because aggregating from atoms is always possible and disaggregating never is, so the atomic grain answers questions nobody asked at design time. The exception is when the atomic volume is genuinely unaffordable, in which case keep the atoms in a lower layer and document where they live.

What happens if a fact table mixes grains?

Measures double count on any join or filter that touches the finer level, and totals change depending on which dimension a report includes. It is one of the hardest defects to spot from the data alone, which is why a uniqueness test on the grain columns belongs in CI.

Can one business process have several fact tables?

Yes, and it is a normal pattern: an atomic transaction fact, a periodic snapshot at daily or monthly grain, and sometimes an accumulating snapshot for the milestone view. Each has its own grain sentence and its own valid measures, and each is built from the atomic table rather than from the source again.

How do you document the grain so it does not get lost?

Put the sentence in the model file next to the SQL, enforce it with a uniqueness test on the grain columns, and repeat it on the fact node in the architecture diagram. Documentation that lives in the same commit as the code is the only kind that stays true across a year of changes.