← All cheatsheets
Data Engineer · #032 · August 24, 2026 · 3 min read

What is the difference between fact and dimension tables?

The two-question test that sorts any column into fact or dimension in five seconds, the join pattern behind every BI query, and the trap of numbers hiding in dimensions.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

The data modeling pop quiz: which table gets the numbers? You have a fact_orders with amount, quantity and discount, a dim_customer with name, city and segment, and a column like unit_price circling both tables looking for a home. Eight columns to sort, and most people misplace at least two.

The two-question test

Any column, sorted in five seconds:

  • Measured? Recorded per event, and you would SUM or AVG it: fact.
  • Describes? A property of a person, product or place you GROUP BY: dimension.

Two rules keep the test honest:

  • The grain: one fact row = one event. Say it out loud: "one row per order line".
  • The keys: facts carry foreign keys to every dimension that gives them context.

The quiz, answered

Eight columns from an orders warehouse. Sort before you peek.

ColumnGoes toBecause
amountfactyou sum it
quantityfactmeasured per order
customer_citydimensionyou group by it
product_colordimensiondescribes the product
discountfactan amount, per event
order_datebothdate_key in fact, dim_date describes it
unit_pricefactcopy it at sale time
customer_ageneitherstore birth_date, derive age

The last three rows are where the points are lost, and each one is a trap covered below.

The join pattern

Facts bring the numbers, dimensions bring the words. Every BI query is this:

SELECT d.city,
       SUM(f.amount) AS revenue
FROM fact_orders f
JOIN dim_customer d
  ON f.customer_key = d.customer_key
GROUP BY d.city
ORDER BY revenue DESC;

Numbers from the fact, labels from the dimension. If you are summing a dimension column, stop: either the column is in the wrong table or the query is.

The trap: numbers in dimensions

Two famous ones. Both look reasonable, both bite later.

Price in dim_product. Prices change. Last year's orders now report this year's price the moment the catalog updates. Copy price into the fact at sale time, so each order remembers what was actually paid.

Age in dim_customer. Ages itself stale in twelve months. Store birth_date, compute age in the query.

The smell to memorize: a numeric column in a dimension that changes over time is a slowly changing fact in disguise.

Where these tables live

Sheet 007 (star vs snowflake) covers how fact and dimension tables are arranged into a schema; this one covers what goes inside them. Get the inside right first: a star schema full of misplaced columns is still a broken model.

The takeaway

Measured per event: fact, sum it. Describes a thing: dimension, group by it. State the grain out loud, keep the numbers that change over time in the fact, and every BI query reduces to the same join pattern. The two-question test, the full quiz and the join pattern fit on the print-ready PDF above.

Frequently asked questions

How do you decide if a column is a fact or a dimension?
Two questions. Is it measured, recorded per event, something you would SUM or AVG? Fact. Does it describe a person, product or place, something you would GROUP BY? Dimension. Amount and quantity are facts; customer_city and product_color are dimensions. Any column sorts in five seconds.
Does unit_price go in the fact table or the dimension table?
In the fact table, copied at sale time. Prices change: if unit_price lives in dim_product, last year's orders start reporting this year's price the moment the catalog updates. Freezing the price into the fact row keeps history correct.
What is the grain of a fact table?
What one row represents, stated out loud: one row per order line, one row per page view, one row per payment. Every measure in the table must be true at that grain, and every foreign key must point to a dimension that gives that event context. If you cannot say the grain in one sentence, the table is not designed yet.
Why should you not store age in a dimension table?
Because age ages itself stale in twelve months, and any numeric column in a dimension that changes over time is a slowly changing fact in disguise. Store birth_date in the dimension and compute age in the query. Same logic behind copying price into the fact at sale time.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

More cheatsheets