skip to content

Surrogate Keys & Effective Dating

Type 2 only works if the fact table points at a version of a dimension row rather than at the business entity. That means warehouse surrogate keys plus effective-from/to windows and a current-row flag.

on this pageshow

questions

6

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

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

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

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

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