Slowly Changing Dimensions: SCD Types 0 Through 7, With dbt Examples
Types 0 through 7, side by side — what each SCD type stores, what it forgets, when to reach for each one, and how to implement Type 2 cleanly with dbt snapshots.
A customer moves cities. A product gets recategorised. A salesperson changes territory. In the source system this is a routine UPDATE — one row changes and life goes on. In the warehouse it's a question with real money attached: what should happen to every fact row that referenced the old value?
That question — how a warehouse handles attribute change — is the whole of slowly changing dimensions. There are seven canonical answers, numbered 0 through 7, plus a couple of hybrids, and picking the right one per attribute is one of the higher-leverage modeling decisions a team makes. Choose Type 1 for something that should have been Type 2, and last quarter's revenue-by-region report suddenly reflects this month's territory map. Choose Type 2 for something that should have been Type 1, and your dimension quietly grows without limit.
This is a walk through every SCD type with a running example, tables you can read, dbt code for the ones that need it, and a decision framework for picking the right type per column — not per table.
Why SCDs Matter
A dashboard that answers "what was revenue by customer segment last quarter?" only produces the right number if the segment your query pulls is the one that was in effect at order time — not the one that's in effect today. That property is called time consistency, and it's what SCDs exist to preserve.
Break time consistency and every historical comparison silently drifts. Segment definitions get restated. Sales-by-region charts reorganize themselves whenever a rep changes territory. Cohort analyses stop being reproducible — the same query returns different numbers on Tuesday and Thursday, and nobody can tell why. None of this shows up as an error; it shows up as an argument in a review meeting six months later, and by then the source data has moved on.
SCDs are how you decide, per attribute, whether the warehouse remembers the past. "Per attribute" is the important part: a dimension row usually has some columns that should track history (segment, city, price band) and others that should not (email address correction, cleaned-up name capitalisation). A well-modeled dimension is a mix of SCD types, chosen column by column.
The Test Question — One Customer, One Move
The example that runs through the whole post: customer C-8842, whose city changes from Mumbai to Bengaluru on 2026-03-10, and again to Hyderabad on 2026-09-20. Every SCD type is a different answer to "what does dim_customer look like today, and what happens when you ask about an order placed in July 2025?"
| date | what happened in the source | what the warehouse needs to remember |
|---|---|---|
| 2024-01-15 | customer C-8842 registered, city = Mumbai |
starting state |
| 2026-03-10 | customer updated their address to Bengaluru | previous city was Mumbai for ~2 years of orders |
| 2026-09-20 | customer updated their address to Hyderabad | previous city was Bengaluru for ~6 months of orders |
Nothing in this scenario is exotic. Every warehouse has millions of tiny updates like this a year. The SCD choice decides which of them leave a scar on the data.
Type 0 — Retain Original
Type 0 is the "we don't care what changes; store the value as it was on the day the row was first inserted" strategy. The column is written once and never updated, even if the source system changes it.
what the row looks like
| customer_sk | customer_id | original_signup_city | current_city |
|---|---|---|---|
| a3f9…c4 | C-8842 | Mumbai | — |
Two moves later, original_signup_city is still Mumbai. That's the point.
where it wins
Attributes that are conceptually anchored to the moment they were captured — original acquisition channel, first-order date, sign-up geography, the coupon code used at registration. These are business-meaningful precisely because they never change, and letting them change would destroy their meaning.
where it hurts
Almost never applied by accident, but sometimes applied to fields where it shouldn't be. If current_city is a Type 0 by mistake, you end up with a "current" column that hasn't been current in three years. Name Type 0 columns to make the "as-of-signup" nature obvious in the column name, not in a data-dictionary entry three clicks away.
Type 1 — Overwrite
Type 1 is the "update in place, forget the old value" strategy. On every load, the dimension row is upserted; the column now shows the current value from the source, and history is not preserved.
what happens to our customer
| customer_sk | customer_id | city | updated_at |
|---|---|---|---|
| a3f9…c4 | C-8842 | Hyderabad | 2026-09-20 |
Mumbai and Bengaluru are gone. If you now run "revenue by city, 2025", every order this customer placed while living in Mumbai is attributed to Hyderabad — because the fact table joins to whatever city currently sits on the dimension row.
{{ config(materialized='incremental', unique_key='customer_sk') }}
select
{{ dbt_utils.generate_surrogate_key(['source_system','customer_id']) }} as customer_sk,
customer_id,
customer_name,
email,
city,
segment,
current_timestamp() as updated_at
from {{ ref('stg_customers') }}
{% if is_incremental() %}
where _loaded_at > (select max(updated_at) from {{ this }})
{% endif %}
where it wins
Fields where the current value is the only meaningful value, and history is either uninteresting or already captured elsewhere. Corrected typos, normalised phone numbers, refreshed email addresses. Also anything downstream of a data-quality pipeline where the "old value" was wrong and remembering it would be misleading.
where it hurts
Type 1 is the default that gets picked because it's the easiest to build, and then quietly ruins historical reporting. If you ever hear "the numbers on this dashboard changed and I don't know why", the answer is often "we're Type-1ing a column that a report is grouping by".
Type 2 — Add New Row (The Workhorse)
Type 2 is what most people mean when they say "SCD". The dimension keeps every historical version of a row as separate physical rows, each with a validity window (valid_from, valid_to) and an is_current flag. When an attribute changes, the current row is closed off and a new row is inserted with the new value.
what happens to our customer
| customer_sk | customer_id | city | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| a3f9…c4 | C-8842 | Mumbai | 2024-01-15 | 2026-03-10 | false |
| e7d1…9a | C-8842 | Bengaluru | 2026-03-10 | 2026-09-20 | false |
| f204…7b | C-8842 | Hyderabad | 2026-09-20 | 9999-12-31 | true |
Three rows for one business customer. Each has a different customer_sk — the surrogate key is version-specific. Fact rows always join to the version of the dimension that was current at the fact's own timestamp, which preserves history perfectly.
the "as-of" join
-- Every order picks up the customer version that was in effect at order_date
select
o.order_id,
o.order_date,
o.net_amount_inr,
c.city as city_at_order_time,
c.segment as segment_at_order_time
from {{ ref('fct_orders') }} o
left join {{ ref('dim_customer') }} c
on c.customer_id = o.customer_id
and o.order_date >= c.valid_from
and o.order_date < c.valid_to
Notice the >= on valid_from and the strict < on valid_to. Half-open intervals matter — a strict-less-than on valid_to means adjacent rows don't overlap, and every timestamp maps to exactly one version. Get this wrong and you either double-count (both intervals inclusive) or lose the boundary date entirely (both exclusive).
A common upgrade: also store the current surrogate key on the fact row itself, so "everything about this customer as of now" is one join away without an interval predicate. Two surrogate keys per FK feels wasteful; it isn't. It costs two integers and saves an enormous amount of query complexity for the majority of dashboards that only care about the current picture.
where it wins
Attributes whose historical value drives historical reporting. Customer segment. Product category. Sales territory. Price tier. Anything you might group by in a "last-quarter-versus-this-quarter" query, or that legal or finance might need to reconstruct.
where it hurts
Type 2 dimensions grow. If you Type 2 a column that changes weekly on a dimension with a million customers, that's fifty-two million rows a year, and the fact table's join fan-out gets ugly. Be selective — Type 2 the columns that need it, Type 1 the rest, and if you Type 2 an attribute that changes every day, ask whether it's really a dimension attribute or actually a measurement that belongs on a fact table.
Type 3 — Add New Attribute
Type 3 keeps a fixed, small amount of history by adding one or more previous_x columns to the row. On a change, the current value shifts into previous_x and the new value takes its place. History depth is bounded by the number of columns you're willing to add.
what happens to our customer
| customer_sk | customer_id | city | previous_city | city_changed_at |
|---|---|---|---|---|
| a3f9…c4 | C-8842 | Hyderabad | Bengaluru | 2026-09-20 |
Type 3 remembers one previous state. Mumbai is gone forever; only the Bengaluru → Hyderabad transition survives.
where it wins
Reorganisations. When the sales VP redraws territories mid-year and the reporting question is "how would revenue look under the old territory map versus the new one?", Type 3 gives you both attributes on one row, with no interval join. Also useful for a currency or classification change where "before and after" is the only comparison anyone wants.
where it hurts
Type 3 forgets everything but the most recent change. If the attribute changes a second time, the value that was in previous_x is overwritten. It's not a general history solution — it's a two-column snapshot.
Type 4 — History Table
Type 4 splits the dimension in two: a current table with one row per customer holding the latest values (Type 1-style), and a history table alongside it holding every version of the row with validity windows (Type 2-style). Fact tables join to whichever one their query needs.
what the two tables look like
| dim_customer_current | ||
|---|---|---|
| customer_sk | customer_id | city |
| a3f9…c4 | C-8842 | Hyderabad |
| dim_customer_history | ||||
|---|---|---|---|---|
| customer_version_sk | customer_id | city | valid_from | valid_to |
| a3f9…c4 | C-8842 | Mumbai | 2024-01-15 | 2026-03-10 |
| e7d1…9a | C-8842 | Bengaluru | 2026-03-10 | 2026-09-20 |
| f204…7b | C-8842 | Hyderabad | 2026-09-20 | 9999-12-31 |
where it wins
Very wide dimensions where a handful of attributes change often but most queries only need the current picture. The current table stays narrow and fast; the history table absorbs the version explosion without slowing down "just show me today" dashboards. Type 4 is also easier for BI tools that struggle with interval-join semantics — most queries just hit the current table.
where it hurts
Two tables to keep in sync, two places to define what "customer" means, and a real risk of the current row and the newest history row drifting apart if one load succeeds and the other fails. Prefer Type 2 unless the dimension is genuinely wide (dozens of columns) and the query mix is heavily "current only".
Types 5, 6, and 7 — Hybrids
Once you know the first four types, the rest are combinations. Kimball's group formalised them because they turn up over and over in real warehouses.
Type 5 — Type 4 with a current-values outrigger
A Type 4 (current + history) where the current table is joined into fact-consuming views as an outrigger, so analysts can pull "current segment" alongside "as-of-order-date segment" in the same query without writing a second join clause. In practice this is Type 4 with a materialised view on top; the "type number" is really about how it's presented to the query author, not how it's stored.
Type 6 — combined Type 1 + Type 2 + Type 3
The one you'll see most often after Type 2 itself. A single dimension row carries all three at once:
| customer_sk | customer_id | city (historical, Type 2) | current_city (Type 1, overwrites on every row) | previous_city (Type 3) | valid_from | valid_to |
|---|---|---|---|---|---|---|
| a3f9…c4 | C-8842 | Mumbai | Hyderabad | Bengaluru | 2024-01-15 | 2026-03-10 |
| e7d1…9a | C-8842 | Bengaluru | Hyderabad | Bengaluru | 2026-03-10 | 2026-09-20 |
| f204…7b | C-8842 | Hyderabad | Hyderabad | Bengaluru | 2026-09-20 | 9999-12-31 |
The historical city column varies row-to-row (Type 2). The current_city column is the same value on every row for this customer, always overwritten to the latest (Type 1). The previous_city holds the most recent previous value (Type 3). One join, three ways to look at the same attribute — powerful, at the cost of storage and load-side complexity.
Type 7 — dual foreign keys on the fact table
Instead of putting the different views on the dimension, put two keys on the fact table: the version surrogate key (points at the row current when the fact happened) and the durable customer key (points at the same customer across all versions). Analysts pick which one to join on depending on the question.
Type 7 is the shape you converge on for very high-scale warehouses with strong analyst discipline. It pushes the "which version?" choice out of the dimension shape and into the query, which is more flexible but requires everyone consuming the data to know what they're doing.
Choosing an SCD Type — Per Column, Not Per Table
Textbook diagrams often show a dimension "of Type 2", as though the whole table has one type. Real dimensions almost never do. A useful mental model is: for each column, ask two questions.
- Does anyone group by, filter on, or report against this column over time? If no → Type 1 is fine. If yes → probably Type 2.
- Would restating history be wrong or misleading? If yes → Type 2. If it doesn't matter (typo fix, phone normalisation) → Type 1.
A few practical rules that fall out:
- Identifiers → Type 0. The business key never changes; the surrogate key on a Type 2 row is version-specific but the business key is stable.
- Descriptive attributes that shape reporting → Type 2. Segment, category, tier, territory, region.
- Cleaned-up display fields → Type 1. Normalized name, corrected email, canonicalised phone.
- Very-wide, mostly-current dimensions → Type 4 or 6. Product masters with 60 columns where only a handful change historically.
- Anchoring attributes → Type 0. Original acquisition channel, first-order date, sign-up geography, birth city.
- Attributes that change more than once a week → question whether they belong on the dimension. High-velocity change is usually a measurement, not a description.
Implementing SCD Type 2 with dbt Snapshots
dbt bakes Type 2 into a first-class feature called snapshots. A snapshot is a small YAML/SQL config that, on each run, compares the source data against the snapshot table and inserts new rows for any records whose tracked columns changed. It handles the versioning, the dbt_valid_from/dbt_valid_to/dbt_scd_id plumbing, and closing off the previous row automatically.
{% snapshot customer_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='check',
check_cols=['city', 'segment', 'tier', 'sales_territory'],
invalidate_hard_deletes=True
)
}}
select
customer_id,
customer_name,
email,
city,
segment,
tier,
sales_territory,
is_active
from {{ source('crm', 'customers') }}
{% endsnapshot %}
Notes on the config that matter:
strategy='check'compares the columns incheck_colsrow-by-row. Use'timestamp'instead if your source guarantees a reliableupdated_at— it's cheaper on wide tables but only correct if the timestamp is actually updated whenever any of the tracked columns change.check_colsis the explicit list of Type-2 columns. Everything not listed is effectively Type 1 — the snapshot doesn't watch it, so a change there doesn't create a new row and the value gets overwritten on the current row.invalidate_hard_deletes=Truecloses the current row when a customer disappears from the source. Without this, deleted rows quietly stay marked as current forever.- Snapshots write to a dedicated
snapshotsschema. Treat that schema as append-only ground truth — restoring from a bad snapshot backfill is much harder than restoring a mart.
Downstream, a Type 2 dimension is a thin model on top of the snapshot that renames the dbt columns to the ones your team standardises on:
{{ config(materialized='table') }}
select
{{ dbt_utils.generate_surrogate_key(['customer_id','dbt_valid_from']) }} as customer_sk,
customer_id as customer_bk,
customer_name,
email,
city,
segment,
tier,
sales_territory,
dbt_valid_from as valid_from,
coalesce(dbt_valid_to, '9999-12-31') as valid_to,
(dbt_valid_to is null) as is_current
from {{ ref('customer_snapshot') }}
union all
-- The unknown member row — catches orphan fact FKs at build time
select
'-1' as customer_sk,
'-1' as customer_bk,
'Unknown' as customer_name,
null::string,
'Unknown' as city,
'Unknown' as segment,
'Unknown' as tier,
'Unknown' as sales_territory,
'1900-01-01'::date,
'9999-12-31'::date,
true
Two properties fall out of this shape. First, customer_sk is version-specific (it's derived from customer_id plus valid_from), so it's stable across snapshots and joins reliably from facts. Second, is_current is derived from dbt_valid_to being null, not stored separately — that avoids the classic bug where is_current = true on two rows because a load half-succeeded.
Snapshot early, even before anyone has asked for history. You can't reconstruct changes that happened before you started capturing them, and "we need to know what the customer looked like six months ago" is one of those requests that always shows up eventually.
Common Mistakes
- Type 2-ing the whole table. If every column is versioned, the dimension explodes at the rate of the noisiest column. Pick which columns need history and Type 1 the rest via the
check_colslist. - Closed-closed validity windows. Using
valid_from <= t <= valid_toeither double-counts on boundary dates (both endpoints inclusive) or drops them (both exclusive). Half-openvalid_from <= t < valid_tois the only shape that partitions time cleanly. - Missing the unknown member. Without a
customer_sk = '-1'row, a fact FK that doesn't match any dimension row produces a silent inner-join drop. Report totals get quietly short, and nobody notices until an auditor does. - Both
is_currentflags = true. Derive it fromvalid_to is nullor a sentinel'9999-12-31'. Storing it as a real column that a load has to update makes it possible for two rows to be current after a failed run. - Joining facts to
where is_current = truefor historical reports. That's an accidental Type 1. If the report is about the past, join on the interval or on the version SK the fact carries. - Snapshotting from a mart instead of from a source. Snapshots should watch the closest-to-raw representation of the entity. Snapshot a mart and you're capturing history of your own transformations, which changes every time you deploy.
Wrapping Up
SCDs sound like a taxonomy question and are really a decision framework. Every attribute on every dimension has an answer: this one keeps history, that one doesn't, this other one keeps just the previous value. Get those answers right and time-consistent reporting is free; get them wrong and you spend the next year explaining why the same query returned different numbers this week and last.
Type 2 is the workhorse and the default for anything you'll group by over time. Type 1 is the right answer for anything whose old value was wrong or uninteresting. Type 0 is for the small set of attributes anchored to a moment. Type 3 is a two-column snapshot for reorganisations. Type 4 splits current from history when the dimension is wide. Types 5, 6, and 7 are hybrids the rest of the world uses when a single approach isn't enough.
Pick per column, not per table. Start snapshotting early. Test that is_current is unique per business key and that intervals don't overlap. And when someone asks why the segment on this dashboard doesn't match what's in the CRM today — you'll have the right answer, because you designed for it on purpose.
— This article is part of an ongoing data-engineering series on techedge.in. Wrestling with an SCD decision? Drop a comment, I read every one.
One email, every other week.
New posts on data engineering, applied AI, and the business decisions around them. No noise, unsubscribe anytime.
Comments
All comments are reviewed before they appear publicly — this keeps spam out.
Loading comments…