SCD Type 6 blends historical accuracy with current-state query performance by stamping the latest attribute value across every historical row. This deep-dive shows you how to build a complete, production-ready Type 6 dimension in dbt using snapshots, incremental models, and merge strategies — with hybrid alternatives for when Type 6 alone isn't enough.

Imagine you're a senior analytics engineer at a mid-size e-commerce company. You've been asked to answer a deceptively simple question: "What was the average order value by customer segment last quarter, using the segment each customer was in at the time they placed the order?"
If your customer dimension is a standard SCD Type 1 — where updates overwrite history — you can't answer this accurately. The segment assigned to a customer today overwrites whatever they were last quarter. But if you went all-in on SCD Type 2, you can answer the historical question, though your analysts now complain that queries are slow and they keep accidentally double-counting customers because they forgot to filter on is_current = true. You're caught between historical fidelity and operational simplicity.
This is the exact problem that SCD Type 6 was designed to solve. Type 6 is sometimes called the "hybrid" SCD because it blends Type 1, Type 2, and Type 3 together in a single row design — giving you the full historical record of changes alongside a current-state attribute stamped on every row. By the end of this article, you'll have a complete, production-ready dbt implementation of SCD Type 6, understand when to reach for hybrid variants, and know how to tune the approach for query performance at scale.
What you'll learn:
is_current anti-patternYou should be comfortable with:
snapshot feature and incremental materializationsBefore writing a line of dbt code, you need to deeply understand what Type 6 actually is structurally, because its design is what enables the query performance gains.
In a standard SCD Type 2 implementation, a customer dimension looks like this:
| customer_key | customer_id | segment | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| 1001 | C-500 | Bronze | 2022-01-01 | 2023-03-14 | false |
| 1002 | C-500 | Silver | 2023-03-15 | 2024-07-01 | false |
| 1003 | C-500 | Gold | 2024-07-02 | 9999-12-31 | true |
To answer "what was this customer's segment when they placed order #8899 on 2023-05-10?", you join orders to the customer dimension where the order date falls between valid_from and valid_to. That's a range join — and range joins are expensive on large tables because most query planners can't use standard equality-based indexes or clustering keys for them efficiently.
SCD Type 6 adds a current_segment column (or whatever your tracked attribute is) to every historical row, always populated with the customer's most recent value:
| customer_key | customer_id | segment | current_segment | valid_from | valid_to | is_current |
|---|---|---|---|---|---|---|
| 1001 | C-500 | Bronze | Gold | 2022-01-01 | 2023-03-14 | false |
| 1002 | C-500 | Silver | Gold | 2023-03-15 | 2024-07-01 | false |
| 1003 | C-500 | Gold | Gold | 2024-07-02 | 9999-12-31 | true |
Now, for any query that only needs to know a customer's current state — not their state at time of transaction — you can join to customer_key directly and read current_segment off any row. No range join, no is_current filter, no fan-out risk.
Key insight: The Type 6 pattern doesn't eliminate the historical rows — it adds a denormalized "current value" column to each one. Historical accuracy is preserved through the
segmentcolumn. Current-state performance is enabled throughcurrent_segment. The tradeoff is storage: you're storing the current value redundantly across every historical row.
This "1 + 2 + 3 = 6" naming convention refers to the three SCD types it combines: Type 1 (overwrite current value in place across all rows), Type 2 (add a new row for each change), and Type 3 (store a previous value as an additional column). The result is a dimension table that can serve both analytical and operational query patterns without forcing analysts to think carefully about join logic every time.
Let's work with a realistic example: a dim_customers table for an e-commerce platform. The tracked slowly-changing attributes are customer_segment and country_code. The grain is one row per customer per version.
Here's the target schema:
-- Target: dim_customers (SCD Type 6)
customer_key BIGINT -- surrogate key
customer_id VARCHAR -- natural/business key
email VARCHAR -- Type 1 (always current, overwrite in place)
customer_segment VARCHAR -- Type 2 (historical value at this version)
country_code VARCHAR -- Type 2 (historical value at this version)
prev_customer_segment VARCHAR -- Type 3 (value before most recent change)
current_segment VARCHAR -- Type 6 (always the latest segment, on every row)
current_country_code VARCHAR -- Type 6 (always the latest country, on every row)
valid_from TIMESTAMP
valid_to TIMESTAMP
is_current BOOLEAN
dbt_scd_id VARCHAR -- dbt snapshot hash
Notice that email is treated as a Type 1 attribute — corrections to email addresses should propagate to all historical rows, not create new versions. customer_segment and country_code are Type 2 attributes where we want the historical value preserved. current_segment and current_country_code are the Type 6 overlay — always current, always denormalized across all rows.
dbt's snapshot feature is the right primitive for building the Type 2 historical record. If you haven't used it, Slowly Changing Dimensions in Practice: Handling Historical Data Changes in Your Warehouse has a solid walkthrough.
Create snapshots/customers_snapshot.sql:
{% snapshot customers_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='check',
check_cols=['customer_segment', 'country_code'],
invalidate_hard_deletes=True
)
}}
select
customer_id,
email,
customer_segment,
country_code,
updated_at
from {{ source('crm', 'customers') }}
{% endsnapshot %}
We use strategy='check' rather than strategy='timestamp' because we want dbt to track changes to specific columns (customer_segment and country_code). Email changes are deliberately excluded from check_cols — they'll update in place via a Type 1 pattern later.
After running dbt snapshot, your customers_snapshot table in the snapshots schema will look like a standard SCD Type 2 table with dbt_valid_from, dbt_valid_to, dbt_scd_id, and dbt_updated_at columns added automatically.
Warning: dbt snapshots use
dbt_valid_to IS NULLto identify the current row — not anis_currentboolean. When you build models on top of the snapshot, filter withwhere dbt_valid_to is nullto get current records. Don't add anis_currentboolean in the snapshot itself; keep that logic in downstream models where it's easier to maintain.
Now we transform the raw snapshot into a full Type 6 dimension. This is an incremental model that sits on top of the snapshot. Create models/marts/dim_customers.sql:
{{
config(
materialized='incremental',
unique_key='customer_key',
on_schema_change='sync_all_columns',
cluster_by=['customer_id', 'is_current']
)
}}
with snapshot_base as (
select
{{ dbt_utils.generate_surrogate_key(['dbt_scd_id']) }} as customer_key,
customer_id,
email,
customer_segment,
country_code,
dbt_valid_from as valid_from,
coalesce(dbt_valid_to, '9999-12-31'::timestamp) as valid_to,
dbt_valid_to is null as is_current,
dbt_scd_id
from {{ ref('customers_snapshot') }}
{% if is_incremental() %}
-- Only process rows that changed since last run, plus current rows
-- that need their current_* columns refreshed
where dbt_updated_at > (
select max(dbt_updated_at) from {{ this }}
)
or dbt_valid_to is null
{% endif %}
),
-- Compute the Type 3 "previous value" by looking at the row before the current version
type_3_previous as (
select
dbt_scd_id,
lag(customer_segment) over (
partition by customer_id
order by valid_from
) as prev_customer_segment
from snapshot_base
),
-- Compute the Type 6 current value: the segment on the most recent (current) row
type_6_current as (
select
customer_id,
customer_segment as current_segment,
country_code as current_country_code,
-- Email is Type 1: always use the latest value regardless of which version we're on
email as current_email
from snapshot_base
where is_current = true
),
final as (
select
s.customer_key,
s.customer_id,
-- Type 1: always current email, overwritten in place across all rows
c6.current_email as email,
-- Type 2: historical value at the time of this version
s.customer_segment,
s.country_code,
-- Type 3: previous value before this version
t3.prev_customer_segment,
-- Type 6: current value stamped on every row
c6.current_segment,
c6.current_country_code,
-- SCD metadata
s.valid_from,
s.valid_to,
s.is_current,
s.dbt_scd_id
from snapshot_base s
left join type_3_previous t3
on s.dbt_scd_id = t3.dbt_scd_id
left join type_6_current c6
on s.customer_id = c6.customer_id
)
select * from final
Let's walk through the key design decisions here:
The type_6_current CTE pulls only the current row for each customer and then joins it back to every historical row. This is the denormalization heart of Type 6. Every historical row gets the current segment stamped on it.
The type_3_previous CTE uses LAG() partitioned by customer_id to find the segment value from the immediately preceding version. This is a lightweight audit trail — useful for answering "what changed?" without scanning the full history.
Email as Type 1 is handled by sourcing it from type_6_current (which always has the latest snapshot row) and aliasing it back as email. This means historical rows will show the current email, not the email at the time of that version. That's intentional — corrections to contact information should propagate everywhere.
Tip: On Snowflake, the
cluster_by=['customer_id', 'is_current']config tells Snowflake to physically co-locate rows by customer and then by current status. This dramatically improves performance for two query patterns: looking up a specific customer's history (scan bycustomer_id) and filtering for only current rows (is_current = true). On BigQuery, usepartition_byon a date column derived fromvalid_fromandcluster_byoncustomer_idinstead.
One gotcha with the above approach: customer_key is derived from dbt_scd_id, which dbt generates as an MD5 hash of the unique key and the valid-from timestamp. This means your surrogate keys are stable — a historical row for customer C-500 from 2022 will always have the same customer_key regardless of how many times you re-run the model. That's exactly what you want.
But there's a subtlety with the incremental logic. When a customer gets a new segment, a new row appears in customers_snapshot with a new dbt_scd_id. The previous row also gets updated — its dbt_valid_to gets set. Our incremental filter uses dbt_updated_at, which captures both the new row and the closed-out old row. But the current_* columns on all historical rows need to be updated to reflect the new current segment.
The line or dbt_valid_to is null in the incremental filter is not sufficient on its own. We need to also refresh all historical rows when a new current value appears. The cleanest way to handle this in dbt is to use a merge strategy and update the current_segment and current_country_code columns in place:
{{
config(
materialized='incremental',
unique_key='customer_key',
incremental_strategy='merge',
merge_update_columns=[
'email',
'current_segment',
'current_country_code',
'is_current',
'valid_to'
]
)
}}
By specifying merge_update_columns, we tell dbt to only update those specific columns when a matching customer_key already exists. This means:
is_current = false) will have their current_* columns refreshed when a new version appearscustomer_segment and country_code columns (the historical values) are never updated on existing rows — they're immutable once writtenemail is always updated because it's Type 1This is a critical distinction. The merge strategy with controlled column updates is what makes the Type 6 pattern truly operational at scale.
The biggest quality-of-life improvement you can give analysts is a clean current-state view that doesn't require them to think about SCD at all:
-- models/marts/dim_customers_current.sql
{{
config(materialized='view')
}}
select
customer_id,
email,
current_segment as customer_segment,
current_country_code as country_code,
valid_from as segment_since,
prev_customer_segment
from {{ ref('dim_customers') }}
where is_current = true
This view reads from the Type 6 table but presents a flat, single-row-per-customer surface. Analysts who don't care about history use dim_customers_current. Analysts doing temporal analysis use dim_customers directly with date-range joins on valid_from / valid_to.
Note: In most column-store warehouses, a view over a filtered incremental table is extremely cheap — the warehouse will push the
is_current = truepredicate into the underlying scan and use your clustering/partitioning to skip irrelevant data. You're not materializing a separate copy, just providing a logical abstraction.
For the fact-table join pattern — the one that was slow with range joins — analysts can now do this:
-- Segment-based revenue analysis using Type 6 current_segment
-- (when they want current segment applied to all historical orders)
select
c.current_segment,
date_trunc('month', o.order_date) as order_month,
sum(o.order_amount) as total_revenue,
count(distinct o.customer_id) as unique_customers
from orders o
join dim_customers c
on o.customer_id = c.customer_id
and c.is_current = true
group by 1, 2
order by 2, 1
And they can do the historically accurate version when needed:
-- Segment-based revenue analysis using Type 2 historical segment
-- (segment as it was at time of order)
select
c.customer_segment,
date_trunc('month', o.order_date) as order_month,
sum(o.order_amount) as total_revenue
from orders o
join dim_customers c
on o.customer_id = c.customer_id
and o.order_date >= c.valid_from
and o.order_date < c.valid_to
group by 1, 2
order by 2, 1
Both queries work. The first uses equality joins (fast). The second uses range joins (necessary for historical accuracy). The Type 6 design makes both possible without maintaining two separate dimension tables.
Type 1 attributes (like email) are tricky in an incremental model because they can change without creating a new snapshot version — you've explicitly excluded them from check_cols. When an email address is corrected, the snapshot won't create a new row, but you do want the change to propagate across all existing historical rows.
The merge_update_columns configuration we set earlier handles new transactions automatically. But for a bulk correction — say, a data quality fix that updates 50,000 email addresses — you need a way to force a full refresh of the email column across all historical rows.
Add a dbt variable to control this:
{% if is_incremental() and not var('force_type1_refresh', false) %}
where dbt_updated_at > (
select max(dbt_updated_at) from {{ this }}
)
or dbt_valid_to is null
{% endif %}
Then run with:
dbt run --select dim_customers --vars '{"force_type1_refresh": true}'
When force_type1_refresh is true, the incremental filter is skipped entirely and the model processes the full snapshot, refreshing Type 1 attributes across all historical rows. This is a targeted full-refresh for Type 1 only, not a destructive rebuild of the entire table.
Type 6 is the most common hybrid, but real-world analytics engineering requires thinking about several adjacent patterns.
For very high-cardinality dimensions (tens of millions of customers), even the merge strategy on Type 6 can be slow if millions of current_* columns need refreshing on each run. One alternative is to not denormalize the current value into every row, but instead maintain a separate lightweight current-state table:
-- models/marts/dim_customers_history.sql (pure Type 2, no Type 6 columns)
-- models/marts/dim_customers_current.sql (single row per customer, materialized table)
You then pre-join them in your semantic layer or in a mart-level aggregation model. This is architecturally cleaner but adds a layer of model dependency. See Data Modeling for Analytics: Dimensional Modeling vs One Big Table for when this separation is the right call.
Some teams implement what's informally called SCD Type 7: the fact table stores two foreign keys — the surrogate key pointing to the historical row (for historical accuracy) and the natural/business key used to join to a current-state view (for current-state queries). This avoids the Type 6 current_* column bloat entirely:
-- orders fact table with dual key
create table fact_orders as
select
order_id,
customer_id, -- natural key, joins to dim_customers_current
customer_key, -- surrogate key, joins to dim_customers for historical accuracy
order_date,
order_amount
from source_orders
The downside: every analyst team needs to know which key to use and when. Type 6 is often easier to govern because the choice lives in the column name (customer_segment vs current_segment), not in the join key.
For analytics patterns where you frequently need the state of a dimension as-of a specific date (month-end reporting, regulatory snapshots), consider a point-in-time table: a pre-computed snapshot of the dimension state at regular intervals.
-- models/marts/dim_customers_monthly_snapshot.sql
{{
config(materialized='incremental', unique_key=['customer_id', 'snapshot_month'])
}}
with months as (
{{ dbt_utils.date_spine(
datepart="month",
start_date="cast('2022-01-01' as date)",
end_date="cast(current_date as date)"
) }}
),
customer_history as (
select * from {{ ref('dim_customers') }}
),
final as (
select
m.date_month as snapshot_month,
c.customer_id,
c.customer_segment,
c.country_code,
c.email
from months m
left join customer_history c
on c.customer_id is not null
and m.date_month >= c.valid_from::date
and m.date_month < c.valid_to::date
)
select * from final
This pattern trades storage for query simplicity. Month-end reports become trivial equality joins on snapshot_month. This is especially valuable for incremental models at scale where reprocessing the full date spine on every run would be prohibitive — partition the incremental model on snapshot_month and only add new months.
Key insight: The monthly snapshot pattern is a form of pre-aggregated Type 6 logic. Instead of stamping current values on every historical row, you stamp the as-of-date value on every pre-computed month. The tradeoff is that you're limited to month-level granularity for historical queries, which is usually fine for business reporting but not for per-transaction analytics.
On Snowflake, your cluster_by choice is critical. For a Type 6 dimension, the two dominant access patterns are:
WHERE is_current = true AND customer_id = ?WHERE customer_id = ? AND order_date BETWEEN valid_from AND valid_toBoth patterns benefit from clustering on customer_id first. Add is_current as a secondary cluster key to accelerate current-state queries. Don't cluster on valid_from or valid_to — Snowflake's micro-partition pruning on date ranges works best when you're scanning full partitions, not filtering inside them.
On BigQuery, partition the table on date(valid_from) and cluster on customer_id. This gives you partition elimination for historical range queries (most queries have a date predicate) and clustering for customer-specific lookups.
A common mistake is adding WHERE is_current = true as an afterthought filter on top of an already-filtered result set. This forces the query to read all historical rows before filtering. In columnar warehouses, this matters less because the is_current column is stored separately and scanning it is cheap — but it's still wasteful.
The cleaner approach: when analysts only need current state, point them to dim_customers_current (the view that pre-applies the filter). Reserve dim_customers for queries that genuinely need history.
The incremental merge on dim_customers becomes expensive as the table grows, because updating current_* columns on all historical rows of recently-changed customers requires touching many rows per merge cycle. Profile your merge queries using your warehouse's query history tooling — if merge time is growing superlinearly, consider:
created_year or some other customer attribute, running separate incremental refreshes per cohort.current_* columns into a dedicated table that's rebuilt as a simple aggregation (SELECT ... WHERE is_current = true) rather than merged row-by-row.The profiling and optimizing dbt models guide covers the diagnostic process in depth.
You'll build a complete SCD Type 6 customer dimension for a retail analytics scenario, starting from raw source data.
Create three source tables in your warehouse to simulate an e-commerce CRM feed. In Snowflake or BigQuery, run this DDL:
-- Raw customers table (simulates CRM source)
create or replace table raw.customers (
customer_id varchar(20),
email varchar(100),
segment varchar(20),
country_code varchar(5),
updated_at timestamp
);
insert into raw.customers values
('C-001', 'alice@example.com', 'Bronze', 'US', '2022-01-01 00:00:00'),
('C-002', 'bob@example.com', 'Silver', 'CA', '2022-01-01 00:00:00'),
('C-003', 'charlie@example.com', 'Gold', 'GB', '2022-01-01 00:00:00');
In your dbt project, create snapshots/customers_snapshot.sql using the check strategy on segment and country_code. Run dbt snapshot and confirm three rows appear in your snapshot table.
Insert updates to simulate segment upgrades:
-- Customer C-001 gets upgraded to Silver
update raw.customers
set segment = 'Silver', updated_at = '2023-06-01 00:00:00'
where customer_id = 'C-001';
-- Customer C-002 moves countries
update raw.customers
set country_code = 'US', updated_at = '2023-09-15 00:00:00'
where customer_id = 'C-002';
-- Customer C-001 gets upgraded again to Gold
update raw.customers
set segment = 'Gold', updated_at = '2024-02-10 00:00:00'
where customer_id = 'C-001';
Run dbt snapshot after each update and verify that new rows are added with the correct dbt_valid_from / dbt_valid_to values.
Create models/marts/dim_customers.sql following the pattern from Step 2 in this article. Run dbt run --select dim_customers and verify:
current_segment = 'Gold'prev_customer_segment = NULL (it was the first)prev_customer_segment = 'Bronze'Run both query patterns against your dimension and verify they return different answers:
-- Pattern A: Current segment for all historical orders
select customer_id, current_segment
from dim_customers
where customer_id = 'C-001' and is_current = true;
-- Expected: Gold
-- Pattern B: Segment at a specific point in time
select customer_id, customer_segment
from dim_customers
where customer_id = 'C-001'
and '2023-07-15'::date between valid_from::date and valid_to::date;
-- Expected: Silver (C-001 was Silver from June 2023 to February 2024)
If both queries return Gold, you've accidentally applied Type 1 logic everywhere — go back and check that customer_segment in your model is sourced from snapshot_base, not from type_6_current.
Symptom: Customer C-001's Bronze and Silver rows still show current_segment = 'Silver' even after the Gold upgrade.
Cause: Your incremental filter only processed the new Gold row but didn't update historical rows.
Fix: Add the merge_update_columns config and ensure current_segment and current_country_code are in that list. Also verify that the type_6_current CTE in your model doesn't have an incremental filter applied to it — it should always read the full current state from the snapshot.
Symptom: Queries that worked in development break in production, returning no rows.
Cause: In your dimension model you've transformed dbt_valid_to IS NULL into an is_current boolean, but somewhere in the model chain you're mixing the two patterns.
Fix: Standardize on is_current in all downstream models. Never expose dbt_valid_to IS NULL logic to analysts — encapsulate it in the transformation layer.
Symptom: Your snapshot is creating new rows when only email changes, even though email is not in check_cols.
Cause: Your source query includes a timestamp column (updated_at) in check_cols, and email changes update the timestamp even when you're using a check strategy.
Fix: Remove updated_at from check_cols. The check strategy should only list the business attributes you want to track as version-creating changes.
Symptom: customer_key values in dev match prod, causing merge conflicts or incorrect attribution when promoting between environments.
Cause: dbt_scd_id is deterministic and content-based, so the same source data will produce the same surrogate keys in both environments.
Fix: This is actually expected behavior and is fine for most use cases. But if your dim_customers is consumed by downstream fact tables that store customer_key as a foreign key, make sure your CI/CD pipeline uses separate schemas and that foreign key integrity tests are environment-aware.
Symptom: After adding new columns to dim_customers, the next incremental run takes 10x longer than usual.
Cause: With on_schema_change='sync_all_columns', dbt drops and recreates columns, which can invalidate clustering in some warehouses and force a full table scan on the next merge.
Fix: After schema changes that affect clustering columns, run dbt run --full-refresh --select dim_customers during a maintenance window and allow the warehouse to re-cluster. Monitor with your warehouse's clustering depth tooling.
Warning: Never run
dbt snapshot --full-refreshin production unless you've explicitly planned for it. A full refresh of a snapshot drops and recreates the snapshot table, destroying all historical records. There is no undo. Keep snapshots on a separate backup schedule and use dbt's state comparison features to validate snapshot changes in CI before they hit production.
SCD Type 6 gives you the best of all worlds — historical accuracy for temporal analytics, current-state performance for operational queries, and a Type 3 audit trail for lightweight change tracking — but it requires deliberate design to implement correctly.
Here's what you've built in this article:
The foundation you've laid here integrates naturally with several adjacent concerns in a production data stack. If your source data is coming in via change data capture from a transactional database, Implementing Change Data Capture with Debezium and Airbyte covers the ingestion side that feeds your snapshots. Once your dimension is stable, you'll want to expose it through a semantic layer — Semantic Layer Implementation: Building and Managing Metrics with dbt Metrics and Cube.js shows how to define metrics on top of SCD dimensions in a way that's query-pattern-aware.
Finally, as your SCD models grow in complexity, lineage tracking becomes essential — knowing which downstream reports depend on dim_customers before you make a structural change is critical. Multi-Hop Data Lineage Tracking Across the Modern Data Stack shows you how to instrument that visibility end-to-end.
The most important thing to take away: SCD Type 6 is not about adding complexity for its own sake. It's about making the right trade-off explicit in your schema design, so analysts can write correct queries intuitively rather than having to understand the mechanics of slowly changing dimensions every time they open a notebook.