A concept in data warehousing to manage how data that changes slowly over time is stored and tracked.
Slowly Changing Dimensions (SCDs) are the pattern that separates "a warehouse that reports the truth at this instant" from "a warehouse that reports the truth as of any point in history." Every dimension attribute — customer address, employee department, product category, sales territory — drifts over time. Your warehouse has to decide: when the CRM updates a customer's city from Hyderabad to Bangalore, does every historical order for that customer now show Bangalore (Type 1, overwrite), or do orders before the move show Hyderabad and after show Bangalore (Type 2, versioned history)? That single decision ripples through every report, every ML feature, and every compliance audit you'll ever run.
The SCD choice feels academic until you hit one of these: (1) regional revenue reports that retroactively shift after a sales territory reorg, (2) ML training sets that leak future information into historical features, (3) auditors asking "what did this customer's KYC status look like on the day of the transaction," (4) churn analysis where "customer segment" keeps changing so cohort definitions drift. Type 1 is fine for corrections (fixing a misspelled name) but lethal for any attribute that influences metrics or ML. Type 2 is the right default for most business-meaningful dimensions in a Kimball-style warehouse.
A B2B SaaS client had 3 years of Type 1 dimensions in Redshift — everything overwritten, no history. When their sales org restructured regions, the quarterly board deck suddenly showed different regional revenue numbers for prior quarters than had been presented before. The CFO was livid. We spent 4 months retrofitting SCD Type 2 onto 12 core dimensions (customer, account, salesperson, territory, product). The hard part wasn't the dbt snapshot logic — it was backfilling history we didn't have (had to reconstruct from Salesforce audit logs) and updating 200+ downstream models to join on valid_from/valid_to with point-in-time correctness. Lesson: pick your SCD strategy before the first dashboard goes to an exec; retrofitting is 10x the work of doing it right up front.
Slowly Changing Dimensions (SCD) are a set of techniques for tracking historical changes in dimension tables within a data warehouse. When attributes of business entities change over time — a customer moves to a new city, an employee gets a new title, a product changes its price tier — you need a strategy for how the warehouse reflects those changes.
Without SCD strategies, your warehouse loses historical context:
UPDATE dim_customer
SET city = 'Bangalore'
WHERE customer_id = 101;
-- Before
| customer_id | name | city | valid_from | valid_to | is_current |
|-------------|-------|------------|------------|------------|------------|
| 101 | Rahul | Hyderabad | 2024-01-01 | 9999-12-31 | true |
-- After city change
| customer_id | name | city | valid_from | valid_to | is_current |
|-------------|-------|------------|------------|------------|------------|
| 101 | Rahul | Hyderabad | 2024-01-01 | 2026-03-15 | false |
| 101 | Rahul | Bangalore | 2026-03-15 | 9999-12-31 | true |
| customer_id | name | current_city | previous_city |
|-------------|-------|-------------|---------------|
| 101 | Rahul | Bangalore | Hyderabad |
dbt has a built-in snapshot feature for SCD Type 2:
# models/snapshots/customer_snapshot.sql
{% snapshot customer_snapshot %}
{{ config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at'
) }}
SELECT * FROM {{ source('raw', 'customers') }}
{% endsnapshot %}| Requirement | Recommended Type |
|---|---|
| No history needed | Type 1 |
| Full audit trail | Type 2 |
| Only previous value | Type 3 |
| Fast current lookups + history | Type 4 or Type 6 |
| Immutable attributes | Type 0 |
SCD implementation traps that cause silent data corruption:
WHEN MATCHED AND clauses).BETWEEN valid_from AND valid_to — getting the boundary conditions wrong (using <= vs <) duplicates or drops rows. Always test with a customer who changed on a boundary date.valid_to = '9999-12-31' vs NULL inconsistency. Pick one convention and enforce it. Mixing both in the same table breaks WHERE valid_to > CURRENT_DATE and WHERE valid_to IS NULL filters simultaneously.is_current = true for the same customer. Add a uniqueness DMF or test that flags this immediately; if it accumulates, every aggregation doubles.valid_from based on load timestamp, not business event timestamp, makes point-in-time joins wrong when the source system delivers changes late. Use the source's updated_at when available.strategy='timestamp' requires a reliable updated_at column; strategy='check' compares column values but gets slow on wide tables. Pick based on source reliability, not convenience.last_login_at as a Type 2 tracked column creates a new row every login. Only track columns that have business meaning for historical analysis.SCD Type 2 inserts a new row in the dimension table every time one or more tracked attributes change, marking the old row as expired and leaving the new row as current. The row includes metadata columns — typically `valid_from`, `valid_to` (or `NULL`/`'9999-12-31'` for the current row), `is_current` flag, and often a version number. This preserves complete history: you can answer "what was this customer's segment on July 15th, 2024" by filtering `WHERE valid_from <= '2024-07-15' AND (valid_to > '2024-07-15' OR valid_to IS NULL)`. Type 2 is the default for any dimension attribute that influences metrics, ML features, or compliance reporting. The cost: table grows with each change, and fact-table joins become date-range joins rather than simple equi-joins.
Type 1 (overwrite, no history) is appropriate only when (1) the previous value has no analytical or compliance meaning — fixing typos, correcting data entry errors, updating coordinates when lat/long precision improves; (2) the attribute is descriptive and never appears in metric definitions or filters — a customer's preferred-name field displayed in UIs but never aggregated on. Type 2 is the correct default for every other case: customer segment, sales territory, product category, employee department, pricing tier, compliance status. The cost of Type 2 (extra storage, slightly more complex joins) is trivial compared to the cost of losing history. When in doubt, go Type 2 — you can always query the current state with `WHERE is_current = true`, but you can't recover history you threw away.
dbt's `snapshot` block automates Type 2: point it at a source model, declare `unique_key` (natural key) and `strategy` (`timestamp` or `check`), and dbt generates the logic to insert new versions when changes are detected. For `strategy='timestamp'`, specify an `updated_at` column; dbt compares values and inserts a new row when the source timestamp is newer. For `strategy='check'`, specify `check_cols` (array of columns or `all`); dbt compares column values directly. Run `dbt snapshot` on your orchestration schedule (typically hourly or daily). dbt manages `dbt_valid_from`, `dbt_valid_to`, `dbt_scd_id` automatically. For complex cases (soft deletes, composite keys, backfills) you may need custom materializations, but `snapshot` handles ~80% of real-world Type 2 needs with a dozen lines of YAML.
SCD Type 6 is a hybrid ("1 + 2 + 3 = 6") that combines overwrite, versioning, and current-value tracking in one table. Each row has: (a) the historical value of the attribute at that row's validity window (Type 2 behavior), (b) a `current_<attr>` column that's updated across ALL rows for that natural key whenever the attribute changes (Type 1 behavior applied to a mirror column), and (c) optionally a `previous_<attr>` column showing the value immediately before (Type 3). Type 6 lets you query "current customer segment as of today" and "historical customer segment at order time" from the same dimension without a self-join. The cost: updates cascade across all historical rows for a natural key, making writes slower. Use when reporting requires both current-state and point-in-time views with low-latency.
Type 0: retain original, never update — suitable for values that should freeze at initial load (original account opening date). Type 3: add columns for previous values (`current_region`, `previous_region`, `region_change_date`) — captures one step of history; useful when you only care about the most recent change but not the full chain. Type 4: split rapidly-changing attributes into a separate "mini-dimension" table, while the main dimension uses Type 1 or 2 — avoids Type 2 row explosion from noisy attributes. Type 7: dual Type 1 + Type 2 tables (or two columns pointing to current and historical surrogate keys) — gives consumers flexibility to choose current or historical joins without applying Type 6 logic. In practice, Type 1 and Type 2 handle 95% of needs; Type 4 and 6 appear in large-scale Kimball warehouses.
With Type 1 dimensions, facts join on natural or surrogate key — simple equi-join. With Type 2, facts must join on the surrogate key that represents the dimension version valid at the fact's event date. Two correct patterns: (1) at ETL time, look up the surrogate key from the Type 2 dimension using fact event date between `valid_from` and `valid_to`, then store that surrogate key on the fact row — joins are simple but backfills require recomputing surrogate keys if dimensions change; (2) store natural key on the fact and do date-range joins at query time (`fact.customer_id = dim.customer_id AND fact.event_date BETWEEN dim.valid_from AND dim.valid_to`) — more flexible but slower and easier to get wrong. Pattern (1) is preferred for high-scale fact tables; pattern (2) works for ad-hoc analytics.
A point-in-time (PIT) table pre-materializes surrogate-key lookups for an "as-of" date, letting fact tables join to multiple Type 2 dimensions without date-range gymnastics. It's common in Data Vault 2.0 architectures where satellites (equivalent to Type 2 dimensions) are used heavily. For each as-of date you care about (daily snapshots, end-of-month, etc.), the PIT table stores the surrogate key from each satellite that was current on that date. Queries join fact → PIT on business key + as-of date → dimensions on surrogate key, turning N date-range joins into N equi-joins. Needed when: (1) you have many Type 2 dimensions to join, (2) point-in-time queries are common, (3) query latency matters. Overkill for simple star schemas with 2-3 Type 2 dimensions.