Skip to content
Back to Paid at Yesterday's Rate
Browse Data ModelingSlowly changing dimensions: Type 2

Slowly changing dimensions: Type 2

Slowly changing dimensions (SCDs) version the dimension row instead of updating it, so a report about the past cannot be rewritten by the present.

8 min read

Overview

Keeping a warehouse current sounds routine. dim_courier describes a small delivery company’s couriers, while fact_deliveries records one row per June delivery and references the courier who made it. The data below is enough to build the report.

dim_courier
courier_idnamehome_depot
1PriyaNorthside
2OmarRiverside
fact_deliveries, June
delivery_idcourier_iddelivered_on
90112026-06-03
90212026-06-11
90312026-06-24
90422026-06-08
90522026-06-19

These tables divide work along fact and dimension roles. The fact stores events, while the dimension supplies the descriptive context used to read them. A depot report joins each delivery to its courier and groups by home_depot.

On July 1, Priya transfers from Northside to Riverside. The operational system naturally updates her single row because dispatch needs her current depot, and the schema accepts that edit. June’s depot report has already been run and filed.

Nothing looks wrong when the update lands. The next section reruns the same June report before and after it, exposing what the current-state edit changes.

The failure

The demo runs June’s depot report twice: before Priya’s transfer and after it. Before pressing play, predict the second result. All five deliveries happened in June, but the dimension will describe July. Watch Priya’s row change and compare the rerun with your answer.

Watch a July update rewrite JuneEach courier holds one row, and all five June deliveries reference these rows by courier_id.
the warehouse as it stands
dim_courier
courier_idnamehome_depot
1PriyaNorthside
2OmarRiverside
history arrives as new rows

The first run returns Northside 3 and Riverside 2. The update then replaces the only depot value Priya has. The same join reads her three June deliveries through that new row, so the rerun returns Riverside 5. Northside disappears from a month it actually served.

No error fires. The update did exactly what dispatch needed, and the report query is unchanged and valid. Its answer changes because a question about June is joined to whatever the dimension says today. With no row carrying Priya’s earlier depot, the warehouse can no longer reconstruct the original result.

Running the report against the updated tables reproduces the rewrite:

select d.home_depot as depot, count(*)::int as deliveries
from fact_deliveries f
join dim_courier d on f.courier_id = d.courier_id
group by d.home_depot
order by d.home_depot;

The single result, Riverside with 5, contains a correct total that can survive a quick review. What it loses is the location of the work, the only fact this depot report exists to explain.

The rule

The update destroyed a fact the business still needs. home_depot was never just one value: Priya worked from Northside through June and Riverside from July onward. Each value carried a period that one overwritable cell could not hold, so recording the new truth erased the old one.

That overwrite is correct in the operational dispatch system, which needs exactly the current roster. Normalization says to store a current fact once. The warehouse has a different job because its questions reach into the past, so current-state design alone cannot answer them faithfully.

Ralph Kimball’s dimensional method calls these attributes slowly changing dimensions, which gives this guide its SCD abbreviation. The method numbers the standard treatments. Type 1 is the overwrite you watched and is legitimate when no one will need the old value. Type 2 preserves the old value, and its rule is short:

When a tracked value changes, close the old row and add a new one.

Adding a row keeps both truths and binds each to the period it was valid. The dimension can now answer questions about then and now. The price is several rows per courier, which breaks the one-row-per-thing assumption. The method below makes those versions safe to use.

The method

Type 2 first gives each version an identity and an interval. Those two moves create a table that the learner can trace before the fact-loading rule is introduced.

  1. 1
    Give every version its own key

    Two rows now describe courier 1, so courier_id cannot remain the primary key. Give each version a surrogate key such as courier_key. The key has no business meaning. Keep courier_id as the natural key that identifies the real courier across versions.

  2. 2
    Bound every version with validity dates

    A pair of validity columns records when each version held. effective_from marks its start, and effective_to marks when the successor begins. The current version leaves effective_to as null because its end is unknown. An is_current flag exposes that open row without a date comparison.

dim_courier_versions
courier_keycourier_idhome_depoteffective_fromeffective_tois_current
11Northside2026-01-152026-07-01false
21Riverside2026-07-01nulltrue
32Riverside2026-03-02nulltrue

Priya now has two versions under one natural key. July 1 is the seam where one window closes and the next opens. Trace each event date against the version rows before revealing the matching rule. Select the row that is valid for a June delivery and for a July 2 delivery, using effective_from and effective_to rather than is_current.

select v.home_depot as depot, count(*)::int as deliveries
from fact_deliveries f
join dim_courier_versions v on f.courier_key = v.courier_key
group by v.home_depot
order by v.home_depot;

The fact-loading rule resolves the effective interval, then stamps the matching courier_key onto each delivery. A version matches an event when effective_from <= event_time AND (effective_to > event_time OR effective_to IS NULL). The later analytic query joins fact_deliveries to the stored surrogate key. It does not recalculate the interval. June’s three Northside deliveries retain courier_key 1, so they always resolve to the Northside version. The report remains Northside 3 and Riverside 2 after later transfers. The trace is complete when it selects the version whose effective interval contains the event time. Filtering is_current returns today’s roster from the same dimension.

The judgment

Type 2 preserves history and charges for it. Every change adds a row, every consumer must understand that one courier can have several versions, and the load must detect changes and close windows correctly. Even count(*) needs care because it counts versions rather than couriers.

Ask whether anyone will need an attribute’s value as of an event date. A depot report asks exactly that of home_depot, so it earns Type 2. A courier’s phone number rarely does, so Type 1 is usually enough. Make the choice per attribute because one dimension can carry both treatments.

When the business needs every entity’s state at every period end, the question changes shape and calls for snapshot fact types. Type 2 remains the fit for history recorded when occasional changes occur rather than on a schedule.

Pitfalls

The join that pays twice

A half-adopted Type 2 inflates instead of rewriting. If a fact keeps courier_id and joins the versioned dimension on it, each delivery matches every version of its courier. Priya’s June rows double after her second version appears. Stamp courier_key so every fact reaches exactly one version.

  • Counting versions as couriers. Once one courier can occupy several rows, count(*) answers how many versions exist. Count distinct courier_id, or filter is_current when the question is the present roster.
  • Windows that overlap or gap. Use contiguous half-open windows where each effective_to equals the next effective_from. An overlap returns two versions for one date, while a gap returns none. Close the old row and open the new one in the same load step.
  • A stale current flag. is_current repeats what an open window already says and can drift after a failed load. Derive or verify it from validity columns instead of trusting the flag alone.
  • Versioning a value that never sits still. Type 2 assumes occasional change. A daily-changing value creates excessive versions, while a value that changes with every delivery is a measure and belongs on the fact table.

Practice

Recap

  • Use Type 2 when a report must recover an attribute as of an event date. Use Type 1 when only the current value matters.
  • Close the old row and open the new row, then stamp each fact with the version key valid on its event date.
  • Audit for one current row per natural key, no window gaps or overlaps, and exactly one version match per fact.
  • Snapshot facts fit when the business asks for every entity’s state at every period instead of occasional change history.

SCD and history modeling

Slowly changing dimension choices decide whether analysis sees the world as it looks today or as it looked when the business event happened. Before choosing a type, ask which attribute changed, who will query the old value, and how much history the business actually needs.

Type 1 overwrite

Use Type 1 when only the current value matters. The old value is replaced in place.

Good fit:

  • correcting a misspelled employee name
  • storing the current office phone extension
  • keeping a current-only employee directory

Common mistake: using Type 2 because "history is always safer." Type 2 adds rows, joins, and operational cost. If the product never analyzes prior values, that history can be waste.

Type 2 row versioning

Use Type 2 when historical reporting must use the attribute value that was valid when a fact happened. A stable natural key identifies the business object, while a surrogate key identifies each version row.

Good fit:

  • product price and category history
  • customer tier at purchase time
  • seller region at listing time

A strong Type 2 dimension has a surrogate primary key, the natural business identifier as an attribute, effective-from and effective-to timestamps, and usually a current-row flag for operational queries.

Type 3 previous value

Use Type 3 when the business only needs one prior value, not unlimited history. The prior value is stored as another attribute on the current row.

Good fit:

  • current sales territory plus previous sales territory
  • current account manager plus immediately prior account manager

Common mistake: building Type 2 when the question only asks for before/after comparison across one realignment.

Volatile attributes and mini-dimensions

Some attributes change so often that Type 2 would create noisy row churn. Move those volatile values into a fact, snapshot, or mini-dimension instead of versioning the whole customer row every time.

Good fit:

  • loyalty point balance changes
  • behavior buckets
  • demographic or risk segments that update frequently

Put history at the grain where it belongs. Versioning the whole customer row for one volatile attribute puts it in the wrong place.

The choice

Ask four questions:

  1. Does only current state matter? Choose Type 1.
  2. Must facts join to the value that was valid at event time? Choose Type 2.
  3. Is only one prior value needed? Choose Type 3.
  4. Would Type 2 create row explosion for one volatile attribute? Split that attribute into a fact, snapshot, or mini-dimension.

Kimball slowly changing dimension guidance is the pattern source for Type 1, Type 2, and Type 3 tradeoffs. Kimball dimensional modeling techniques also anchor mini-dimension and history split patterns when one volatile attribute would make the base dimension too noisy.

Practice this concept

Challenges that apply what this lesson covers.