Every data warehouse eventually hits the same wall: someone updates a customer record, and the old value vanishes. The analyst running a report on last quarter’s regional breakdown gets this quarter’s data instead. The audit team asks “what was this customer’s tier on March 15th?” and nobody can answer.
SCD Type 2 solves this by keeping every version of a record with validity timestamps. The problem isn’t understanding the concept. The problem is implementing it without drowning in merge logic, deduplication edge cases, and late-arriving deletes.
Databricks Declarative Pipelines (what used to be called Delta Live Tables) has a feature called APPLY CHANGES that handles all of this. You declare what the keys are, what column to sequence by, and which columns to track. The framework generates the merge logic, handles out-of-order events, and maintains the __START_AT / __END_AT timestamps for you.
What SCD Type 2 Actually Looks Like #
A standard dimension table stores the current state:
| customer_id | tier | region | |
|---|---|---|---|
| C001 | [email protected] | gold | US-WEST |
An SCD Type 2 table stores every state:
| customer_id | tier | region | __START_AT | __END_AT | |
|---|---|---|---|---|---|
| C001 | [email protected] | silver | US-EAST | 2025-01-15 | 2025-06-01 |
| C001 | [email protected] | gold | US-WEST | 2025-06-01 | NULL |
The row with __END_AT = NULL is the current version. Historical queries filter by date range. Simple in theory. In practice, you need to handle:
- Out-of-order arrivals: An update with
updated_at = 2025-05-15arrives after one withupdated_at = 2025-06-01 - Duplicate events: The same update is delivered twice by the CDC source
- Deletes: The source system removes a record, and you need to close out the SCD chain
- Late-arriving deletes: A delete event arrives after newer updates have been processed
Writing this by hand means a multi-way MERGE statement with careful timestamp arithmetic. Every team that does this builds slightly different logic, and the edge cases bite at 3am.
Architecture #
flowchart LR
subgraph Source["CDC Source"]
S["Cloud Storage
(JSON/Parquet CDC events)"]
end
subgraph Pipeline["Declarative Pipeline"]
R["customers_raw
(streaming table)"]
SCD["customers_scd2
(APPLY CHANGES)"]
end
subgraph Downstream["Consumers"]
CDF["Change Data Feed"]
D["Dashboard / Analytics"]
end
S --> R --> SCD
SCD --> CDF --> D
style S fill:#3498db,color:white
style R fill:#e67e22,color:white
style SCD fill:#2ecc71,color:white
style CDF fill:#9b59b6,color:white
style D fill:#f39c12,color:white
Step 1: Set Up the Landing Zone #
Create a catalog, schema, and volume for the CDC data:
|
|
Generate synthetic CDC events. In production, these come from Debezium, Fivetran, or a similar tool:
|
|
Step 2: Build the Declarative Pipeline #
Create a new Declarative Pipeline (DLT) notebook. The pipeline has two tables: a raw ingestion table and the SCD2 target.
Raw Ingestion #
|
|
SCD Type 2 with APPLY CHANGES #
This is where the magic happens. Instead of writing merge logic, you declare the transformation:
|
|
What APPLY CHANGES does under the hood:
- Sequences by
updated_at– so out-of-order events get inserted at the correct position in the history chain - Tracks changes on specified columns – only creates a new SCD2 row when
email,tier,region, orlifetime_valueactually changes - Handles deletes – when
operation = 'DELETE', it closes the current SCD2 row by setting__END_AT - Deduplicates – if the same event arrives twice with the same
updated_at, it’s idempotent
Step 3: Deploy and Run #
Configure the pipeline in the Databricks UI or via the API:
|
|
target field is legacy (from DLT). In Declarative Pipelines, use catalog and schema separately to specify the output location.
Run the pipeline. On first execution, it processes all existing files. Subsequent runs pick up only new files.
Step 4: Query the SCD2 Table #
Current State (Latest Version) #
|
|
Point-in-Time Query #
|
|
Who Changed Tier Recently? #
|
|
Step 5: Enable Change Data Feed for Downstream #
Change Data Feed (CDF) lets downstream consumers read only the rows that changed, instead of rescanning the full table:
|
|
Read the changes incrementally:
|
|
|
|
Testing the Edge Cases #
The point of using APPLY CHANGES is that it handles the hard cases. Here’s how to verify:
Out-of-Order Events #
|
|
After the pipeline runs, check that the SCD2 chain for C001 correctly inserts the silver/EU-WEST row at its proper chronological position, splitting existing ranges if needed.
Duplicate Events #
Write the same event twice with identical updated_at. The pipeline should produce the same result as processing it once. Run the pipeline, check row counts – no duplicates.
Late Delete #
|
|
The pipeline should close the SCD2 row that was active at 2025-04-01 without affecting rows with later __START_AT values.
Gotchas #
__START_AT / __END_AT semantics. These are generated and managed by the framework. Don’t try to set them manually. __START_AT is inclusive, __END_AT is exclusive. A row with __END_AT = NULL is the current version.
Pipeline retry behavior. If a pipeline run fails mid-batch, the next run replays from the last checkpoint. Because APPLY CHANGES uses sequence-based deduplication, replayed events don’t create duplicate SCD2 rows. But this means your CDC source must be replayable (which cloud storage naturally is, Kafka with retention also works).
Column list matters. If you omit a column from track_history_column_list, changes to that column won’t trigger a new SCD2 row. This is a feature, not a bug – you probably don’t want a new row every time last_login_at updates. Be intentional about what you track.
Late-arriving deletes. When a delete event arrives with a timestamp that falls between existing SCD2 rows, the framework handles it correctly by adjusting the chain. But if deletes arrive very late (weeks after the fact), you should verify the resulting history makes business sense. The framework is mechanically correct, but “customer was deleted on March 1st” arriving in April might need manual review.
Handling schema evolution. If the source adds a new column, you need to update the column_list and potentially track_history_column_list in the pipeline definition, then do a full refresh. There’s no automatic column discovery for APPLY CHANGES.
Conclusion #
Hand-coding SCD Type 2 is one of those problems where the basic case is straightforward and the edge cases are brutal. Out-of-order events, duplicate deliveries, late deletes, idempotent replay – each one adds another conditional branch to your merge statement, and the interaction between them creates bugs that only surface in production at the worst possible time.
APPLY CHANGES in Declarative Pipelines compresses all of that into a declaration: here are my keys, here’s my sequence column, here’s what to track. The framework generates the merge logic, handles the edge cases, and produces a clean SCD2 table with __START_AT / __END_AT timestamps that just work.
Pair it with Change Data Feed, and downstream consumers get efficient incremental reads instead of full table scans. That’s a complete CDC-to-SCD2-to-downstream pipeline with maybe 50 lines of actual code. The rest is configuration.