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.
| courier_id | name | home_depot |
|---|---|---|
| 1 | Priya | Northside |
| 2 | Omar | Riverside |
| delivery_id | courier_id | delivered_on |
|---|---|---|
| 901 | 1 | 2026-06-03 |
| 902 | 1 | 2026-06-11 |
| 903 | 1 | 2026-06-24 |
| 904 | 2 | 2026-06-08 |
| 905 | 2 | 2026-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.
| courier_id | name | home_depot |
|---|---|---|
| 1 | Priya | Northside |
| 2 | Omar | Riverside |
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.
- 1Give every version its own key
Two rows now describe courier
1, socourier_idcannot remain the primary key. Give each version a surrogate key such ascourier_key. The key has no business meaning. Keepcourier_idas the natural key that identifies the real courier across versions. - 2Bound every version with validity dates
A pair of validity columns records when each version held.
effective_frommarks its start, andeffective_tomarks when the successor begins. The current version leaveseffective_toasnullbecause its end is unknown. Anis_currentflag exposes that open row without a date comparison.
| courier_key | courier_id | home_depot | effective_from | effective_to | is_current |
|---|---|---|---|---|---|
| 1 | 1 | Northside | 2026-01-15 | 2026-07-01 | false |
| 2 | 1 | Riverside | 2026-07-01 | null | true |
| 3 | 2 | Riverside | 2026-03-02 | null | true |
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
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. Countdistinct courier_id, or filteris_currentwhen the question is the present roster. - Windows that overlap or gap. Use contiguous half-open windows where each
effective_toequals the nexteffective_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_currentrepeats 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:
- Does only current state matter? Choose Type 1.
- Must facts join to the value that was valid at event time? Choose Type 2.
- Is only one prior value needed? Choose Type 3.
- 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.
- Pick an SCD patternQuick decision
- Price history with SCD2Concept challenge
- Protect recycled identitiesConcept challenge
- Paid at Yesterday's RateJudgment tier
- Recycled source idsQuick decision
- Late-arriving dimensionConcept challenge
- Late-arriving fact (SCD2)Concept challenge
- When not to Type 2Quick decision
- Product price and category historyConcept challenge
- Avoid SCD2 row explosionConcept challenge
- Demographic mini-dimensionConcept challenge
- Type 3: previous valueQuick decision
- SCD strategy per attributeConcept challenge
- Mini-dimensionsConcept challenge
- Marketplace activation dropOpen scenario
- Meta marketplace listingsConcept challenge
- Amazon seller conformanceConcept challenge
- Netflix title history (SCD2)Concept challenge
- Stripe MRR snapshotsConcept challenge
- Retail conformed dimensionsConcept challenge
- Healthcare encounter snapshotConcept challenge