skip to content

Slowly Changing Dimensions & History

What happens when a customer moves city or a product changes category: overwrite the past, or keep a versioned history you can report against. SCD Type 2 is probably the single most-asked warehouse-modeling question.

on this pageshow

explore

questions

24

In a Type 2 warehouse dimension, why does each version of a row get its own surrogate key?

level: juniorimportance: must knowfreq 78%

answer

  1. one row per entity, or per version?
  2. two identifiers doing two different jobs
  3. which one does the fact table store?
  4. one is stable forever, one is per row
  5. business key names the entity; surrogate names the version

basics

~20 s

Because facts must point at the version of the dimension that was true when the event happened. The business key identifies the entity for its whole life; the warehouse surrogate key identifies one dated version of that entity.

solid answer

~50 s

A Type 2 dimension keeps history by inserting a new row every time a tracked attribute changes, so a single customer can occupy many rows. The **business key** (the source system's `customer_id`) is the same on all of them — it identifies the entity, and it is what you group by when you want "this customer across all time". The **surrogate key** is a warehouse-generated identifier that is unique per *row*, so it identifies one version: customer C7 as they were between March and July. Fact tables carry the surrogate key, which is what freezes the historical context: a March order joins to the March version and keeps the March region even after the customer moves. If facts carried the business key instead, every historical report would silently re-point at whatever the customer looks like today.

code

text · 6 lines
text
customer_sk | customer_id | region | effective_from | effective_to | is_current
----------- | ----------- | ------ | -------------- | ------------ | ----------
       4471 | C7          | EMEA   | 2024-01-10     | 2024-08-02   | false
       9088 | C7          | APAC   | 2024-08-02     | 9999-12-31   | true

-- an order placed 2024-03-14 stores customer_sk = 4471, not C7

go deeper

for a junior

Be ready to state plainly that a Type 2 dimension holds several rows for the same entity, that the business key is repeated across them, and that the warehouse surrogate key is unique per row and is what fact tables store.

for a middle

Explain the mechanics: the load resolves business key plus event timestamp to one surrogate key, and every later query is then a plain equality join with no date logic. Show why storing the business key on the fact fans out.

for a senior

Show the consequences you have lived with: distinct-entity counts that must use the durable key, exports that must never leak surrogate keys, and old reports staying reproducible because facts are welded to a version.

for a principal

Own the convention across the platform — every versioned dimension carries both keys, facts carry only surrogate keys, and the durable key is the contract with the outside world while the surrogate key stays a private, regenerable handle.

## The problem this solves A dimension table describes an entity — a customer, a product, a store. In a source system that entity has exactly one row, updated in place. In a warehouse that tracks history with SCD Type 2, a change to a tracked attribute does not overwrite the row; it closes the old row and inserts a new one. The customer now occupies two rows, and will occupy five after four more changes. That immediately breaks the assumption that one identifier means one row. You need two different identifiers doing two different jobs. ## The two keys **The business key** (also called the natural key, or the *durable* key when you want to stress the point) is the identifier the source system uses: `customer_id = 'C7'`. It is identical on every version of that customer. It answers "which entity is this?" and it is what you group by for "total revenue per customer, all time, regardless of how many times they moved". **The surrogate key** is a warehouse-generated identifier — `customer_sk` — that is unique per *row of the dimension*, not per entity. C7's March version and C7's August version get different surrogate keys. It answers "which version of which entity is this?" It is the dimension's primary key, and it is the column fact tables foreign-key to. The surrogate key means nothing to the business and should never be shown to a user or matched against a source system. Its whole purpose is to be a stable handle on one row of history. ## What the fact table sees When a fact row is loaded, the ETL resolves the event's business key plus its timestamp against the dimension's effective windows and writes the *resolved surrogate key* into the fact: ```sql -- resolve once, at load time SELECT d.customer_sk FROM dim_customer d WHERE d.customer_id = :order_customer_id AND :order_ts >= d.effective_from AND :order_ts < d.effective_to; ``` From then on, every report is a plain equality join `fct_orders.customer_sk = dim_customer.customer_sk`. No date logic at query time, no range predicate, and — critically — no way for a later dimension change to alter what an old report says. The March order is welded to the March version. ## What breaks if you skip it If the fact table stores the business key instead, one of two bad things happens. Either the join matches *every* version of the customer, and the fact fans out and multiplies revenue by the number of versions; or you add a range predicate to every query and every analyst has to remember it, and the ones who forget produce the fan-out. Either way the model has pushed a correctness requirement out of the schema and into the query authors. If instead you keep only one row per customer and overwrite it (Type 1), the join is safe but the history is gone: a report of last year's sales by region re-attributes revenue to wherever the customer lives now, and last year's number changes every time someone edits a record. ## The durable key still has to be in the dimension A common design slip is to build the versioned dimension with only the surrogate key as an identifier. The business key must stay as a column on every version, because several real questions need it: - "How many *distinct customers* bought?" — `COUNT(DISTINCT customer_id)`, not `COUNT(DISTINCT customer_sk)`, which counts versions. - "Show me all of this customer's history" — filter the dimension on the business key. - "Report old facts by the customer's *current* attributes" — hop from the fact's version row to the business key, then back to the row currently in force. So the dimension typically has: `customer_sk` as primary key, `customer_id` as the durable business key, the tracked attributes, the effective window and a current-row flag. ## Compound business keys and multi-source dimensions When a dimension is fed by more than one system, the business key alone may not be unique — two systems can both issue `C7`. The durable key then becomes the pair (source system, source identifier), and the surrogate key is what hides that ugliness from every downstream query. This is one of the quieter reasons warehouses use surrogate keys at all: the fact table's join column stays a single narrow integer no matter how messy the upstream identifiers are. ## The one-line version The business key identifies the thing. The surrogate key identifies the thing *as it was*. Type 2 history only works because facts reference the second, not the first.

  • If the surrogate key identifies a version, how do you count distinct customers?
    Count the durable business key, not the surrogate key. `COUNT(DISTINCT customer_sk)` counts dimension *versions*, so a customer who moved twice is counted three times. Join the fact to the dimension and count `DISTINCT customer_id`. This is why the business key has to remain a column on every version rather than being replaced by the surrogate.
  • Should the surrogate key ever be shown to end users or sent back to a source system?
    No. It is a warehouse-internal handle with no business meaning, and it can change if the dimension is rebuilt. Anything leaving the warehouse — a report label, an export, a reverse-ETL payload — should carry the business key. Exposing the surrogate key creates outside dependencies on a value the warehouse team assumed it was free to regenerate.
  • What happens to old fact rows when a new version of a dimension row is inserted?
    Nothing — and that is the point. Old facts keep pointing at the surrogate key they were loaded with, so they continue to resolve to the attribute values in force at the time. Only facts loaded after the change pick up the new surrogate key. History stays reproducible without touching a single fact row.

A business key is a person's name; a surrogate key is one dated photograph of them. A caption saying "taken at the Berlin flat" stays true forever, even after they move.

saying these in an interview costs you the question

  • Says the surrogate key identifies the customer rather than one version
  • Puts the business key on the fact table and joins on it
  • Drops the business key from the dimension once a surrogate exists
  • Counts distinct surrogate keys to count distinct entities
  • Thinks a new version reuses the same surrogate key with new dates

context

open as a page

What is the difference between an SCD Type 1 and an SCD Type 2 change in a dimension table?

level: juniorimportance: must knowfreq 88%

basics

~20 s

A Type 1 change overwrites the attribute in place, so only the current value exists and history is lost. A Type 2 change retires the old row and inserts a new version, so old facts still report under the value that was true then.

open as a page

How does a hashdiff column detect changed rows in an SCD Type 2 dimension load?

level: middleimportance: must knowfreq 70%

basics

~20 s

A hashdiff hashes the concatenated tracked attributes of an incoming source row and compares that value to the hash stored on the key's current dimension version. Equal means skip the row; different means close the old version and insert a new one.

open as a page

An early-arriving fact references a customer key absent from the customer dimension — what are your options?

level: middleimportance: must knowfreq 55%

basics

~20 s

Never drop the row. Either route the fact to the dimension's reserved unknown member, or — the usual choice — insert an inferred placeholder dimension row carrying the natural key, and enrich it when the real record arrives.

open as a page

What does an SCD Type 3 previous-value column let a business report?

level: middleimportance: must knowfreq 60%

basics

~20 s

An SCD Type 3 column keeps one prior value beside the current one on the same dimension row, so the same measures can be reported under both the old and the new attribute at once. It captures a realignment, not a timeline.

open as a page

How do you make an SCD Type 2 dimension load idempotent when the job reruns?

level: seniorimportance: must knowfreq 60%

basics

~20 s

Make the load comparison-driven, not append-driven: version a member only when the incoming hash differs from its current version, derive effective dates from a deterministic batch or source timestamp rather than wall-clock now, deduplicate the source per key, and enforce uniqueness on key plus start date so a duplicate fails loudly.

open as a page

A late-arriving fact is loaded against the dimension's is_current row — what does that break?

level: seniorimportance: must knowfreq 50%

basics

~20 s

It attributes the event to the wrong dimension version. A six-week-old order keyed to today's current customer row lands in the customer's new segment or territory, so historical reports shift and the Type 2 history you paid to maintain is bypassed entirely.

open as a page

Why does a fact table store the dimension surrogate key resolved at load time rather than the business key?

level: seniorimportance: must knowfreq 66%

basics

~20 s

Resolving once at load freezes each fact against the dimension version that was true when the event happened, and turns every later query into a plain equality join. Storing the business key instead pushes a range predicate into every query, and forgetting it fans facts out across versions.

open as a page

In an SCD Type 2 load, why does closing the old row and inserting the new one take two steps?

level: middleimportance: should knowfreq 50%

basics

~20 s

One changed source row implies two writes to the dimension: an update that end-dates the current version and an insert of a new open version. An upsert acts on a matched target row once, so the load runs both writes in one transaction or feeds the merge a doubled source.

open as a page

When the real record for an inferred dimension member arrives, why overwrite it instead of adding a Type 2 version?

level: middleimportance: should knowfreq 38%

basics

~20 s

Because the placeholder attributes were never true. A Type 2 version would record a fake history in which the customer really was named "Unknown". Overwrite in place, keeping the surrogate key so existing facts stay attached.

open as a page

What does an is_current flag add to a Type 2 dimension that already stores effective dates?

level: middleimportance: should knowfreq 58%

basics

~20 s

Nothing new in information — it is derivable from the effective window — but it makes the common "as things are now" query a single cheap boolean filter instead of a date comparison. The cost is redundant state that must be flipped in the same operation that end-dates the old row.

open as a page

How should effective_from and effective_to be set in a Type 2 dimension so every instant maps to one row?

level: middleimportance: should knowfreq 70%

basics

~20 s

Use half-open windows: effective_to of a closed version equals the effective_from of the next one, and the join predicate is ts >= effective_from AND ts < effective_to. End the open row with a far-future sentinel, not NULL, so ranges have no gaps or overlaps.

open as a page

Why does an SCD Type 4 mini-dimension help when a dimension attribute changes weekly?

level: middleimportance: should knowfreq 45%

basics

~20 s

Versioning a weekly-changing attribute would multiply the dimension into tens of versions per member. SCD Type 4 moves those volatile attributes into a small separate table of distinct value combinations that the fact row joins to directly, leaving the base dimension stable.

open as a page

Your nightly SCD Type 2 snapshot load did not run for three days — how do you recover the history?

level: seniorimportance: should knowfreq 40%

basics

~20 s

You can only recover history the source can still replay. With a change log or CDC stream, replay the missed changes in order and insert backdated versions. With only a current-state snapshot, you get one version per changed key — date it from a trustworthy source timestamp, otherwise from the first missed day, and document the gap.

open as a page

In an SCD Type 2 load, what does a source updated_at strategy miss that comparing tracked columns catches?

level: seniorimportance: should knowfreq 45%

basics

~20 s

An updated_at strategy trusts the source to stamp every write, so it misses bulk backfills, trigger-bypassing writes and rows with a NULL timestamp, and it creates spurious versions when the stamp moves without a tracked attribute changing. Comparing tracked column values depends on nothing the source maintains.

open as a page

How do you retro-fit a late-arriving dimension change into an SCD Type 2 chain that already has newer versions?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Splice it into the middle of the chain, not onto the end. End-date the existing version whose window covers the change date, insert the missed version starting at that date and ending where the next version begins, and leave the newest row's current flag untouched.

open as a page

How do you report historical facts by a customer's current region when facts point at old dimension versions?

level: seniorimportance: should knowfreq 52%

basics

~20 s

Hop through the durable business key: join the fact to its stored version to get the business key, then join that back to the version currently in force. The fact's own surrogate key gives the as-was value; the second hop gives the as-is one.

open as a page

What does an SCD Type 6 hybrid dimension let an analyst report that Type 2 alone cannot?

level: seniorimportance: should knowfreq 42%

basics

~20 s

An SCD Type 6 dimension carries a current-value column alongside its Type 2 versions, so the same star answers both as-it-was and as-it-is: group by the versioned column for history, or by the current column to roll a member's whole history under today's value.

open as a page

How far back should late-arriving data be allowed to restate a warehouse's published history?

level: principalimportance: should knowfreq 28%

basics

~20 s

Set an explicit lateness window per subject area, driven by each source's observed arrival lag and by which periods the business has closed. Inside the window, restate; outside it, book corrections forward and never silently change a signed-off number.

open as a page

How do you set SCD tracking policy per attribute on a widely used customer dimension?

level: principalimportance: should knowfreq 38%

basics

~20 s

Decide the SCD type per column, not per dimension: Type 2 only where a report must read a value as it was, Type 1 for corrections and cosmetic labels, Type 0 for origin facts, Type 4 for volatile ones. Then document and test the policy.

open as a page

When should a Type 2 dimension's effective windows use timestamps rather than calendar dates?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

Use timestamps when an entity can change more than once a day or when facts carry intra-day event times. Date-grain windows collapse same-day changes into zero-length or overlapping periods, so the point-in-time lookup cannot tell those versions apart.

open as a page

Which dimension attributes belong under SCD Type 0, where the original value is never changed?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

SCD Type 0 suits attributes that describe an origin and are true forever: the durable business key, the signup or first-order date, the original credit band, the branch an account was opened at. The value is written once and later source updates are ignored.

open as a page

After retro-fitting a missed Type 2 dimension version, which existing fact rows must be repointed?

level: seniorimportance: nice to knowfreq 24%

basics

~10 s

Only facts whose event date falls inside the newly inserted version's window and that still carry the surrogate key of the version that was shortened. Repoint exactly those; facts outside the window are untouched.

open as a page

How do you add a tracked attribute to a live SCD Type 2 dimension without exploding its history?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Recompute the stored change hash for existing current rows under the new column list in the same deployment. Otherwise every stored hash was built over the old list, every incoming hash differs, and the next run closes and re-versions every member for no business reason.

open as a page