Dimensional modeling

Slowly changing dimensions: SCD types with real SQL

A customer moves from São Paulo to Lisbon. Should last year's revenue-by-country report change? That question is the whole topic — and SCD types are the answers.

The problem in one example

2024-03-01  customer 42  region = "LATAM"   -> ordered $500
2025-01-15  customer 42  region = "EMEA"    -> ordered $300

If you overwrite the region, the 2024 order retroactively becomes EMEA revenue and your historical dashboards silently change. If you keep both versions, each order joins to the region that was true when it happened.

Type 1 — overwrite (no history)

merge into dim_customer t
using stg_customer s on t.customer_id = s.customer_id
when matched then update set
  t.region     = s.region,
  t.email      = s.email,
  t.updated_at = current_timestamp()
when not matched then insert (customer_id, region, email, updated_at)
values (s.customer_id, s.region, s.email, current_timestamp());

Correct for fixing typos, correcting bad source data, and attributes nobody reports on historically. Cheap and simple — just be sure nobody needs the old value.

Type 2 — new row per change (full history)

create table dim_customer (
  customer_key  bigint,        -- surrogate key: unique per VERSION
  customer_id   bigint,        -- natural key: stable across versions
  region        string,
  segment       string,
  valid_from    timestamp,
  valid_to      timestamp,     -- '9999-12-31' for the live row
  is_current    boolean
);

-- 1) close out the row whose tracked attributes changed
update dim_customer t
set valid_to = current_timestamp(), is_current = false
from stg_customer s
where t.customer_id = s.customer_id
  and t.is_current
  and (t.region, t.segment) is distinct from (s.region, s.segment);

-- 2) insert the new version
insert into dim_customer
select
  hash(s.customer_id, current_timestamp()) as customer_key,
  s.customer_id, s.region, s.segment,
  current_timestamp(), timestamp '9999-12-31', true
from stg_customer s
left join dim_customer t
  on t.customer_id = s.customer_id and t.is_current
where t.customer_id is null
   or (t.region, t.segment) is distinct from (s.region, s.segment);

Then facts join on the surrogate key, not the natural key:

-- point-in-time join: the version that was active when the order happened
select d.region, sum(f.net_amount) as revenue
from fact_orders f
join dim_customer d
  on d.customer_id = f.customer_id
 and f.ordered_at >= d.valid_from
 and f.ordered_at <  d.valid_to
group by 1;

The same thing in dbt — 8 lines

{% snapshot customers_snapshot %}
{{ config(
    target_schema='snapshots',
    unique_key='customer_id',
    strategy='check',
    check_cols=['region', 'segment']
) }}
select * from {{ source('raw', 'customers') }}
{% endsnapshot %}

Run dbt snapshot and you get dbt_valid_from, dbt_valid_to and dbt_scd_id maintained automatically. This is one of the strongest arguments for dbt in a modern ELT stack.

All the types, quickly

TypeBehaviourUse for
Type 0Never changesBirth date, original signup source
Type 1OverwriteTypos, corrections, non-reported attributes
Type 2New row + validity windowRegion, segment, plan, price tier
Type 3previous_value columnOne-off reorganizations
Type 4Separate history tableFast-changing attributes (mini-dimension)
Type 61 + 2 + 3 combinedNeed both 'as-was' and 'as-is' reporting

Mistakes that show up in code review

  • Joining facts on the natural key in a Type 2 dimension — you'll fan out to every version.
  • Using != instead of is distinct from — nulls make change detection silently skip rows.
  • Nullable valid_to — use a far-future sentinel so range joins work without coalesce.
  • Tracking every column as Type 2 — one noisy column and your dimension doubles weekly.
  • No unique test on (natural key, is_current) — duplicated "current" rows are the classic SCD bug.

FAQ

What are slowly changing dimensions?
Slowly changing dimensions (SCDs) are dimension attributes that change occasionally over time — a customer moving city, a product changing category, an employee changing manager. SCD types describe the strategy you use to record those changes: overwrite (Type 1), keep full history in new rows (Type 2), keep only the previous value in a column (Type 3), and so on.
What is SCD Type 2?
SCD Type 2 keeps history by inserting a new row every time a tracked attribute changes, with valid_from / valid_to timestamps and an is_current flag. Facts join to the dimension row that was active at the time of the event, so historical reports stay correct.
What is the difference between SCD Type 1 and Type 2?
Type 1 overwrites the old value — history is lost and past reports change retroactively. Type 2 preserves the old row and adds a new one, so a report of last year's revenue by region still shows last year's regions.
How do I implement SCD Type 2 in dbt?
Use dbt snapshots. You define a snapshot block with a unique_key and either a timestamp or check strategy, and dbt maintains dbt_valid_from / dbt_valid_to columns for you on every run.
Do I always need SCD Type 2?
No. Type 2 costs storage, complexity, and careful join logic. Use it only for attributes where a historically accurate answer matters — region, segment, price tier, plan. For attributes like a corrected typo in a name, Type 1 is correct.
What are SCD Types 0, 3, 4 and 6?
Type 0 never changes (a birth date). Type 3 keeps a previous_value column — one level of history only. Type 4 moves history to a separate history table. Type 6 combines 1+2+3: current value column plus full row history.

Ready to start?

7 days free. Then less than a coffee per month.

Practice SCD hands-on — 7 days free