DATA ARCHITECTUREFacts

Fact Table Design

How data engineers design data that lasts — pick a topic on the left and its full breakdown loads here: the mental model, ERDs and worked schemas, trade-offs, edge cases, and the decisions that separate a durable model from a fragile one.

18 min readTopics chapter readerLevel · Dimensional & Warehouse
01 · Orientation

What You'll Master Here

A fact table is keys + measures at one grain. Every design choice is about keeping a measure true at that grain, and keeping the evidence for it.

4 min · Topic 1 of 12

Chapter 8 established what a fact table is. This chapter is about the decisions made inside one: which measures earn a column, what an empty measure means, which identifiers get a dimension table, how a header charge reaches line grain, and what happens when a row arrives late or has to be corrected.

Every column below was an argument somebody had. Open each one to see the decision behind it, what it makes answerable, and the bug that ships when it goes the other way.

Core mental model

A fact table is keys + measures at one grain. Every design choice is about keeping a measure true at that grain, and keeping the evidence for it.

Why it matters

Facts hold almost all the data volume and every number anyone reports. Fact-design mistakes are the silent kind: the query runs, returns a number, and the number is quietly wrong at scale.

measure
A numeric value in a fact that you aggregate (amount, quantity, balance).
additivity
Across which dimensions a measure can be safely summed (additive / semi / non).
degenerate dimension
A dimension key (like a bill number) stored on the fact with no dimension table.
restatement
A correction to a fact value that has already been reported.
Nine columns, six decisionsA vending estate's daily fact. Every column is an argument somebody had.
1/6
The table under review
-- GRAIN: one row per vending machine per trading day.
create table fact_vending_day (
  machine_key    bigint not null references dim_machine(machine_key),
  date_key       int    not null references dim_date(date_key),
  machine_serial text   not null,      -- id only: no dimension table
  units_sold     int    not null,      -- additive
  stock_on_hand  int    not null,      -- semi-additive: not across days
  fault_minutes  int,                  -- null = no telemetry, 0 = no fault
  takings_local  numeric(12,2) not null,
  fx_rate        numeric(10,6) not null,
  takings_gbp    numeric(12,2) not null,
  primary key (machine_key, date_key)
);
What this decision buys youOne row per machine per trading day. State that changes slowly (stock left, faults) has a place to live without one row per vend.
Common mistake

Treating every numeric column as freely summable. You sum balances over time or sum ratios and report nonsense; additivity has to be settled per measure, per axis.

Better habit

Classify every measure's additivity before exposing it.

Decide what an empty measure means before the first load, not after the first dispute.

Keep the evidence: source amounts, rates, and versions, not just the derived number.

The big idea

Nearly every bug in this chapter is the same shape: a number that is true at one grain, or in one currency, or at one point in time, being used as though it were true everywhere.

Remember this

Fact design is a sequence of decisions inside one table; each one keeps a measure true at the declared grain, and each has a signature failure when it is decided the other way.