Dimension Tables
The descriptive context you filter and group by — customer, product, date, region — and the reason a calendar deserves a table of its own.
Who needs this, what one row is, and why the obvious build breaks
Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.
Where should the attributes people filter and group by live, and why not simply on the fact table?
Anyone building a filter, a breakdown or a drop-down. A BI tool browses dimensions to offer the fields a user can slice by, so the dimension table is literally the vocabulary the business is allowed to use when asking a question.
One row is one entity, or one version of one entity — one customer, one product, one calendar day, one region. Which of the two it is depends entirely on the history policy, and that ambiguity is the single most consequential thing about dimension design (Slowly Changing Dimensions).
Copy the source entity tables across, keep their primary keys, join facts to them on those keys. It works immediately, the joins are obvious, and the model looks like a star.
A customer moves from Poland to Germany. The dimension is overwritten, and every historical report about Polish revenue silently reassigns that customer's entire order history to Germany. Last quarter's signed-off numbers change (SCD Type 2 in Practice).
- A customer moves from Poland to Germany. The dimension is overwritten, and every historical report about Polish revenue silently reassigns that customer's entire order history to Germany. Last quarter's signed-off numbers change (SCD Type 2 in Practice).
- A product's category is corrected from
AccessoriestoPeripherals. Every past month shifts, and the year-on-year comparison in the board deck no longer matches the one shown last month. - The dimension load runs after the fact load. For a few hours, new fact rows reference keys that do not exist yet, and every inner join drops them — a completeness bug that resolves itself before anyone can reproduce it (Missing Rows).
- A re-run inserts a second row for one customer instead of updating. Every fact joined to that customer is now duplicated, and revenue rises for reasons unrelated to sales (Grain: What Does One Row Represent?).
- Someone needs revenue for weeks with no sales in a category. There are no fact rows for those weeks, so the report simply omits them, and a flat line looks like an absent one (Fact Tables).
What is actually happening
- A dimension holds the descriptive, low-cardinality-per-row context that facts are grouped and filtered by. It is typically wide and short: many columns, comparatively few rows. Facts are narrow and long. That asymmetry is what makes the star schema efficient — the big table stays skinny and the descriptive text lives once, in a table small enough to broadcast (Broadcast Joins).
- Dimensions are joined many-to-one from the fact. That relationship is what preserves the fact's grain, and it holds only while the dimension key is genuinely unique. A dimension with a duplicate key silently turns every star join into a fan-out (Grain: What Does One Row Represent?).
- A date dimension is a table of one row per calendar day, pre-computed with every attribute anyone might group by: month, quarter, ISO week, fiscal period, day of week, holiday flag, business-day flag. It exists because date arithmetic in SQL is dialect-specific, because fiscal calendars do not follow the Gregorian one, and because a table of dates is the only way to get rows for days on which nothing happened (Snapshot Tables).
- A conformed dimension is one shared by several facts with identical keys and meaning, which is what makes cross-process questions possible — orders and support tickets and marketing touches, all sliced by the same customer. Conforming dimensions is mostly an organisational act, not a technical one (Data Ownership).
- Degenerate dimensions are identifiers with no attributes —
order_id,invoice_number— that stay on the fact rather than getting a table of their own. Junk dimensions collect several low-cardinality flags into one small table to keep the fact narrow. Both exist to stop the fact table growing columns.
What a dimension carries, and what stays on the fact
The split is mechanical once stated: numbers that measure the process go on the fact; words that describe the context go in a dimension; identifiers with no attributes stay on the fact as degenerate dimensions. Anything that does not fit one of those three is usually a design question you have not finished answering.
The reason to hold the line is restatement. A country stored on the fact is a value frozen at load time and repeated on every row — correcting it means rewriting history, and there is no single place to look up what it currently is. The same country in a dimension is one row, correctable in one statement, with an explicit policy about whether the correction applies to the past.
The second reason is width. Analytical scans read columns, so every descriptive column on the fact is bytes that a query touching it must read across the whole table, and bytes on disk for every row whether or not anyone reads them (Projection Pushdown).
1-- Descriptive context, versioned. One row per customer VERSION.2CREATE TABLE dim_customer (3 customer_key BIGINT PRIMARY KEY, -- surrogate: identifies a VERSION4 customer_id VARCHAR NOT NULL, -- natural key from the source5 name VARCHAR,6 country VARCHAR, -- SCD2: versioned, history preserved7 segment VARCHAR, -- SCD2: versioned8 email VARCHAR, -- SCD1: overwritten, no history kept9 valid_from TIMESTAMP NOT NULL,10 valid_to TIMESTAMP, -- NULL for the current version11 is_current BOOLEAN NOT NULL12);13-- grain: one row per (customer_id, valid_from). NOT one row per customer.14 15-- Descriptive context, not versioned. One row per product.16CREATE TABLE dim_product (17 product_key BIGINT PRIMARY KEY,18 product_id VARCHAR NOT NULL,19 name VARCHAR,20 category VARCHAR, -- flattened from the source hierarchy21 subcategory VARCHAR, -- snowflaking would split these out22 brand VARCHAR23);24 25-- The calendar, as data. One row per day, generated once, for decades.26CREATE TABLE dim_date (27 date_key INT PRIMARY KEY, -- 20260826, sorts and partitions well28 date DATE NOT NULL,29 year INT, quarter INT, month INT, day_of_month INT,30 year_month VARCHAR, -- '2026-08': groupable without date maths31 iso_week INT, day_of_week INT, day_name VARCHAR,32 is_weekend BOOLEAN,33 is_holiday BOOLEAN, -- per market; often one column per market34 fiscal_year INT, fiscal_quarter INT, -- does not follow the calendar35 is_business_day BOOLEAN36);37 38-- Reserved rows, inserted once, so nothing is ever silently dropped:39INSERT INTO dim_customer (customer_key, customer_id, name, country, is_current, valid_from)40VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown', TRUE, '1900-01-01');Two things to notice. dim_customer mixes SCD1 and SCD2 columns in one table, which is the normal case — history is decided per attribute. And the unknown member exists so that a fact arriving before its dimension row can still be loaded and counted, as a visible Unknown category rather than as a row that an inner join deleted (Slowly Changing Dimensions).
Why a calendar deserves a table
A date dimension looks like the most redundant object in a warehouse. Every engine has date functions; why store the year of 2026-08-26 when EXTRACT will compute it? The answer has four parts and each one is a real reporting failure that a calendar table prevents.
First, fiscal calendars do not follow the Gregorian one. A company whose fiscal year starts in February, or which uses 4-4-5 retail periods, cannot express its own reporting periods with any built-in function, and every analyst reimplements the rule slightly differently.
Second, absence. A fact table is sparse: no row exists for a day with no sales. GROUP BY date therefore returns no row for that day, and a chart shows a gap that looks like missing data rather than a zero. Only a table with a row per day, outer-joined to the fact, can produce the zero.
Third, holidays and business days are business facts, not calendar arithmetic, and they differ per market. Fourth, date functions are the most dialect-divergent part of SQL, so a calendar table is also portability. The whole thing is a few thousand rows for a century of coverage.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A re-run inserts instead of upserting, leaving two rows for one customer. | Revenue rises for that customer's cohort with no matching change in orders. The rise is exactly a factor of two for the affected subset. | The dimension key is no longer unique, so the many-to-one fact join became many-to-many and every matching fact row was duplicated (Grain: What Does One Row Represent?). | Uniqueness test on the dimension key, blocking publication. Then rebuild every fact partition loaded while the duplicate existed — lineage tells you which (Lineage Debugging). |
| The dimension load fails; the fact load runs anyway. | Yesterday's new customers are missing from every breakdown, and the totals are slightly low in a way that resolves itself tomorrow. | Fact rows referenced keys that did not exist, and an inner join dropped them silently. | Order the DAG so dimensions load first, fail the fact load on unresolved keys, and route genuinely unknown context to the unknown member instead of dropping it (Task Dependencies). |
| A category is renamed in the source and the dimension is overwritten. | Last quarter's numbers changed since the last time anyone looked, and no pipeline failed. | A Type 1 overwrite applies retroactively to all history, because history joins to the current dimension row (Slowly Changing Dimensions). | Decide per attribute whether a change is a correction (overwrite) or a real-world change (version). Renames of a taxonomy are almost always the second (SCD Type 2 in Practice). |
| A source migration renumbers customer ids. | Half the fact rows land on the unknown member overnight; the other half are attributed to the wrong customers. | Facts were joined on the natural key, so the key space moved underneath them. | Surrogate keys plus a mapping from old to new natural key, applied during the migration (Surrogate Keys). |
| Two teams each maintain a customer dimension. | Orders and support tickets cannot be joined, and any attempt produces a partial intersection that looks like a real answer. | The dimensions are not conformed: different key spaces, and often different definitions of what counts as a customer. | One owned, conformed dimension with a documented key strategy. This is a governance decision before it is a technical one (Data Ownership). |
Without a date dimension, a sparse fact:
SELECT order_date, SUM(revenue) FROM fct_orders GROUP BY 1
2026-03-09 1200.00
2026-03-10 980.00
2026-03-13 1450.00 <- the 11th and 12th are simply ABSENT
2026-03-14 1100.00 the chart draws a straight line through them
With a date dimension, LEFT JOINed from the calendar:
SELECT d.date, COALESCE(SUM(f.revenue), 0)
FROM dim_date d
LEFT JOIN fct_orders f ON f.date_key = d.date_key
WHERE d.date BETWEEN '2026-03-09' AND '2026-03-14'
GROUP BY 1
2026-03-09 1200.00
2026-03-10 980.00
2026-03-11 0.00 <- a real zero, visible as an outage or a holiday
2026-03-12 0.00
2026-03-13 1450.00
2026-03-14 1100.00
Same data. One version hides a two-day gap; the other makes it the
most obvious thing on the chart.
Fiscal periods no built-in function can produce:
date year_month fiscal_year fiscal_quarter is_business_day
2026-02-01 2026-02 FY2027 Q1 TRUE
2026-01-31 2026-01 FY2026 Q4 FALSEA dimension has a grain too
Facts get the attention when grain is discussed, and dimensions quietly have the same property. "One row is one customer" and "one row is one customer version" are different tables with the same name, the same columns and completely different behaviour on every join and every distinct count.
The consequence people hit first is counting. COUNT(DISTINCT customer_key) means "customers" in an unversioned dimension and "customer versions" in a versioned one, and the metric drifts upward over time in a way that looks exactly like growth. The consequence they hit second is fan-out: joining on the natural key matches every version, and a customer who changed address three times contributes three copies of every order they placed.
The table below is the dimension-side companion to the fact-side grain table. Every row is a legitimate dimension design and each one changes what a join and a count mean. The point is not that one is right — it is that the sentence has to be written down, on the dimension, in the same place a fact declares its own.
| Stage | One row is | Breaks if |
|---|---|---|
| Unversioned dimension (Type 1 throughout) | One entity, holding its current attribute values. | A consumer expects history. Every past-period query is answered with today's attributes, silently, and closed periods change when an attribute does (Slowly Changing Dimensions). |
| Versioned dimension (any Type 2 column) | One entity version over one validity interval, keyed by (natural_key, valid_from). | Anything counts distinct surrogate keys and calls the result entities, or joins on the natural key and matches every version at once (SCD Type 2 in Practice). |
| The `is_current = TRUE` view over it | One entity again, restoring the pre-versioning shape for consumers who only want the present. | A half-completed merge leaves an entity with zero or two current rows, in which case the view either loses the entity or duplicates every fact joined through it. |
| Date dimension | One calendar day, whether or not anything happened on it. | It is joined to a sparse fact with an inner join, which removes exactly the zero-activity days the table existed to provide (Fact Tables). |
| Junk dimension | One distinct combination of several low-cardinality flags, not one entity at all. | Someone counts its rows expecting a population. The row count is the number of flag combinations observed and means nothing about the business. |
| Bridge table (many-to-many) | One pairing — one product in one of its several categories. | It is joined without allocation weights. The fan-out is deliberate here and every measure through it double counts unless the query says how to divide it (Grain: What Does One Row Represent?). |
Six dimension shapes, six different meanings of "one row". Only the first two are usually written down, and the junk and bridge rows are where a confident count comes back meaning something nobody asked for.
How to build it
Most important first.
- Give every dimension a surrogate key and join facts on that, never on the source id. It is what makes versioning possible and what absorbs source-key churn (Surrogate Keys).
- Decide history per attribute. Most columns can be overwritten; the small number that appear in historical reports need versioning, and deciding that per column rather than per table keeps the dimension from doubling in size for no reason (Slowly Changing Dimensions).
- Build a date dimension on day one, covering more years than you expect to need, and make every fact carry a
date_keyinto it. It is the cheapest table in the warehouse and it removes an entire category of date-arithmetic bug (Window Functions). - Load dimensions before facts within a run, and make the fact load fail rather than proceed if a key does not resolve. Silent key loss is the failure mode you least want to be quiet.
- Include an unknown member row — a reserved key such as
-1with descriptive values ofUnknown— so facts with missing or late context can be loaded without being dropped by an inner join, and so the gap is visible as a category rather than as absence. - Keep descriptive text in the dimension and out of the fact. A
customer_countrycolumn on the fact is a value frozen at load time that you can never restate (Analytical Data Modeling).
What this actually promises
Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.
- A dimension guarantees uniqueness on its key only if you test it. Analytical engines generally accept a primary-key declaration without enforcing it, so uniqueness is a property of your pipeline and not of your schema (Database Constraints).
- A conformed dimension guarantees that two facts joined to it mean the same thing by "customer". That guarantee is organisational — it survives only as long as both producers keep using the same key space.
- A date dimension guarantees a row exists for every day in range, which is what makes zero-activity periods expressible. Nothing else in the model provides that.
- A dimension without versioning explicitly guarantees only current-state answers. It will answer a historical question anyway, with today's attribute values and no indication that it did so.
Can I trust it?
A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.
- Test uniqueness on the dimension's key on every load. A duplicate key here is not a dimension problem — it is a fan-out in every fact joined to it, which is a far larger blast radius than the table itself (Data Tests).
- Test referential integrity from the other side: every dimension key referenced by a fact must exist. Track the unresolved rate over time rather than only alerting on zero, because a slow rise means an id space is drifting (The Dimensions of Data Quality).
- Test that the date dimension has no gaps and covers the full range of fact dates, which is a one-line check that prevents a whole family of reporting holes.
- What these miss: attribute correctness. A dimension where every key is unique, every reference resolves and every date exists can still carry a
countryvalue that is stale, mis-mapped or silently defaulted, and no structural test will see it (Distribution Tests).
- Dimensions and facts have different natural freshness. Facts arrive as events occur; dimensions change rarely and are usually rebuilt on a schedule. The mismatch is what produces late-arriving dimension rows (Late-Arriving Data).
- A fact that arrives before its dimension context is a real and common situation — a new customer's first order. Handle it with an unknown member plus a later update, or by holding the fact, and say which one you chose (Late-Arriving Data).
- Dimension attributes are frozen at whatever the last dimension load saw. A daily-refreshed segment column answers today's questions with yesterday's segments, correctly and invisibly.
- Adding an attribute is safe for existing queries and raises one real question: what is the value for history? Null, backfilled, or a sentinel — pick deliberately, because consumers will read null as zero (Nullability & Defaults).
- Changing an attribute's *values* — a category rename, a segment re-definition — is the change with no schema signature and full historical impact, and it is the reason SCD types exist as a decision rather than a default (Semantic Changes).
- Adding versioning to a dimension that never had it changes its grain from one-row-per-entity to one-row-per-version. Every existing join needs a predicate it currently lacks, and that is a breaking change dressed as an improvement (SCD Type 2 in Practice).
- Source-key changes during a system migration are absorbed by surrogate keys and are catastrophic without them (Surrogate Keys).
- Dimensions rebuilt from retained source snapshots recover their *current* state fully. They do not recover versions that were never captured — if the source overwrote a value and no change history was retained, that version is gone from every system (Keeping Raw History: The Recovery Position and the Liability).
- A duplicate-key incident is repaired by fixing the dimension and rebuilding every fact partition loaded while the duplicate existed. Finding those partitions is a lineage question, not a guess (Lineage Debugging).
- Rebuilding a dimension in place while facts are being loaded against it produces a window where keys do not resolve. Build to a new table and swap (Atomic Publish).
What can go wrong
- A duplicate row for one business key, fanning out every fact joined to it.
- A dimension load that ran after the fact load, so an inner join silently dropped the newest rows (Missing Rows).
- An attribute overwritten in place, changing signed-off historical reports with no audit trail (SCD Type 2 in Practice).
- Two facts joined to two different customer dimensions with different key spaces, so a cross-process question is quietly answered on a partial intersection.
- The mitigation failing: an unknown member that absorbs every unresolved key so smoothly that nobody notices the unresolved rate climbing for a month.
- "Dimensions are just lookup tables." A lookup table maps a code to a label. A dimension carries a history policy, a key strategy, an unknown member and a conformance agreement, and every one of those is a decision with downstream consequences.
- "We can use the source id as the dimension key." It works until the first migration renumbers it, or until the first versioned attribute makes it non-unique (Surrogate Keys).
- "A date dimension is unnecessary — dates are a built-in type." Built-in date functions cannot express your fiscal calendar, your holiday list, or a row for a day on which nothing happened.
- "The dimension is small, so it does not matter." It is joined by every query in the model. A defect in a table of ten thousand rows propagates into every number the platform produces (Grain: What Does One Row Represent?).
Operating it
- Row count and distinct key count per dimension per load, on one axis. Separation means duplicates (Pipeline Metrics).
- Unresolved dimension-key rate on each fact load, as a percentage rather than a count, so it is comparable across days (The Data Quality Dashboard).
- Share of fact rows assigned to the unknown member, over time. It should be flat and small; a step change means a source id space moved (Volume Anomalies).
- At 10x facts, dimensions usually do not grow at all, which is the point of the split. What grows is the cost of joining them, which is a layout and join-strategy question (Clustering and Sort Order).
- At 100x, or when versioning is added, a dimension can outgrow broadcast. That changes every star query from a cheap map-side join into a shuffle, and it is a step change rather than a gradient.
- Consumer growth turns conforming into the hard part. Two teams with two customer dimensions is a modelling inconvenience; twelve teams with twelve is a platform that cannot answer a cross-process question at all (Data Ownership).
- Dimensions are small and cheap to store. Their cost is in join behaviour: a dimension small enough to broadcast is nearly free, and one that is not forces a shuffle on every query touching it (Broadcast Joins).
- Versioning multiplies dimension row count by the number of changes per entity, which turns a small table into a medium one and can cross the broadcast threshold (SCD Type 2 in Practice).
- A date dimension is the cheapest table in the warehouse — a few thousand rows for decades of coverage — and removes date arithmetic from every query that would otherwise compute it per row (Compute Waste).
- Separating dimensions from facts costs a join per attribute group and buys the ability to restate an attribute in one place and keep facts narrow. On columnar engines with small dimensions this trade is almost free; it is not free on every engine.
- A date dimension costs a table and a
date_keycolumn on every fact, and buys fiscal calendars, holiday flags, gap-free reporting and dialect independence. - An unknown member costs a reserved key and some care in every join, and buys visible gaps instead of silently dropped facts. It also makes it easier to ignore the gap, which is why the unresolved rate must be monitored.
Modeling lab — one grain, ten questions
Change an input and watch which number moves — and which one does not. Everything here comes from a model in this repository, not from a measurement.
| Business question | At this grain | Why |
|---|---|---|
What was revenue by country last month? Wants: One order, or one order line — either works, provided the measure is additive at that grain and is not summed twice. | answered | The measure is additive at this grain and each order is counted once. |
What is the average order value? Wants: One order. An average over lines answers a different question entirely. | answered | The denominator is orders, which is exactly what one row is. |
What was revenue by customer country at the time of each order? Wants: One order, joined to the version of the customer that was current when the order was placed. | WRONG no history | With a Type 1 dimension every historical order is attributed to the customer's current country. A customer moving from Poland to Germany silently rewrites last year's regional reports, and last month's report no longer reproduces. |
What is net revenue after refunds? Wants: One order, with refunds either netted into the measure or held as a separate signed fact at the same grain. | answered | Refunds net into the measure, or sit beside it as a signed fact at the same grain. |
What was the total account balance on each day last year? Wants: One account-day. A balance is a state, not an event, and cannot be reconstructed by summing transactions unless every transaction since account opening is retained. | WRONG | Summing transactions per day gives the daily change in balance, not the balance. The chart has the right shape and the wrong y-axis. |
What is month-three retention by signup cohort? Wants: One user-month of activity, joined to the user's signup month. | WRONG | Users who were active but did not buy are invisible, so retention is understated by exactly the non-buyers. |
What share of sessions ended in a purchase? Wants: One session — which requires a session window over events, because no source system emits a session. | unanswerable | No source system emits a session. Without a session window over events there is no denominator to divide by. |
Which products are most often bought together? Wants: One order line, with the order key retained so lines can be grouped back into baskets. | unanswerable | The most instructive failure in this lab: the model is not wrong, it is at the wrong resolution, and no query can recover what was aggregated away. |
What was yesterday's revenue, asked at 06:00 this morning? Wants: One order, in a period that is not yet closed. | with care | The grain is right and the period is not closed. Orders that happened yesterday and arrive later today are still missing at 06:00. |
What was global revenue, across markets that bill in different currencies? Wants: One order, with both the transaction amount and the converted amount stored, plus the rate and the date the rate applied. | WRONG no history | Converting at query time with today's rate makes every historical report change daily. Converting once with no record of the rate makes the number unreproducible. Both pass every type check there is. |
Where this applies
Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.
- GENERALSeparating descriptive context from measurements, and joining it many-to-one, is a property of analytical querying rather than of any product. The failure modes — duplicate keys, late dimensions, overwritten attributes — appear identically in a warehouse, a lakehouse and a set of Parquet files.
- WAREHOUSE-SPECIFICWhether keeping dimensions separate is nearly free depends on join strategy: engines that broadcast small tables to every worker make star joins cheap, while an engine that shuffles both sides pays network cost on every query, which is why the same advice about normalising dimensions reverses between engines.
- ORG-SPECIFICConformed dimensions are an agreement between teams about key space and meaning, not a technical artefact. In one team it is trivially satisfied; across a dozen autonomous teams it needs explicit ownership and a shared identifier strategy, which is where mesh-style platforms spend most of their governance effort.
Where the depth lives
This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.
- — DevOps / Production Engineering owns the deployment ordering that makes "dimensions load before facts" an enforced property of the release rather than a convention in a DAG.