Slowly changing dimensions
Lesson 5 of 5 in Data Modeling and Schema Evolution.
A customer moves from the West region to the East. What were last year's sales?
That question has no technically correct answer — it has a business answer, and the slowly changing dimension types are the four ways of encoding whichever one your organisation means.
Type 0 never changes the stored value. Correct for attributes that genuinely cannot change: a date of birth, an original signup channel.
Type 1 overwrites. History is restated as if the customer had always been in the East, so last year's revenue moves regions. One row per entity, cheapest load, and every report run before the change no longer reproduces.
Type 2 closes the current row and inserts a new one with its own surrogate key and validity dates. Each fact stays joined to the attributes that were true when it happened, so last year's sales remain under the West. Full history, and it costs something on every read afterwards.
Type 3 adds a previous_region column beside the current one. It answers exactly one before-and-after comparison, and the second change destroys the first one's evidence.
The question that picks between them is never technical. It is whether the business reports history as it was or as it is now — and both are legitimate. Regulatory reporting and commission calculations usually want as-it-was; a current-state operational dashboard usually wants as-it-is. A warehouse serving both keeps a Type 2 dimension and exposes a current-row view over it, which is the answer worth remembering because it is the one that satisfies both without a second copy.
| Type | What a change does | History | Cost on every read afterwards |
|---|---|---|---|
Type 0 | nothing — the value is never updated | not applicable: the attribute cannot change | none |
Type 1 | overwrites the column in place | none — every report is restated | none, which is exactly why it is chosen and why it misleads |
Type 2 | closes the current row, inserts a new one with a new surrogate key | exact — each fact joins to the version in force at its time | every lookup must qualify the version — |
Type 3 | moves the old value into a | one step only, and the second change destroys the first | none, and the column needs explaining to everyone who reads the table |
Type 2 + a current view | as Type 2, with a view selecting | exact, and the current state is one object away | none for current-state consumers; the qualification lives in the view |
A Type 2 row carries the natural key, a new surrogate key, effective_from, effective_to — conventionally 9999-12-31 rather than null on the open row, so range predicates work without special-casing — and usually an is_current flag. The load closes the old row and inserts the new one, which is naturally one MERGE statement: when matched and the attributes differ, set effective_to and clear is_current; when not matched, insert.
Three costs deserve stating plainly, because they are what a question about "the downside of Type 2" is reaching for.
Every lookup must qualify the version. A query joining customer_id to a Type 2 dimension gets several rows back and silently multiplies its totals. Either is_current = true for current-state reporting, or a date-range join (fact.order_date BETWEEN dim.effective_from AND dim.effective_to) for as-it-was reporting. Forgetting it does not error; it inflates.
The dimension grows with change rather than with population. A hundred thousand customers whose attributes change monthly become 1.2 million rows a year — which is also why a volatile attribute probably does not belong in a Type 2 dimension at all, and belongs in the fact as a degenerate attribute or in its own mini-dimension.
The load has to detect change. Which means reading the current rows on every run and comparing attributes — often with a hash of the tracked columns, so one comparison replaces forty. That read is a real cost on a large dimension, and it is why change data capture from the source is worth having.
On a data lake, all of this is what the table formats made possible: MERGE INTO on an Iceberg table performs the close-and-insert in one atomic commit, so there is no window in which a reader sees two current rows or none. Before them, a Type 2 load on a lake was a full partition rewrite with a visible seam.
Key terms: Slowly changing dimension (SCD), Type 2 dimension, MERGE INTO, Surrogate key, Dimension table
Interactive checkpoint quiz (2 questions) — open this page in a browser to take it.