Delta Live Tables (DLT) is a declarative framework in Databricks for building reliable, automated ETL pipelines. It can automatically manage SCD Type 2 logic, tracking complete historical changes for dimension tables.
DLT simplifies SCD2 by using the dlt.apply_changes function, which automatically:
@dlt.table
def customer_changes():
return spark.readStream.table("silver.customer_changes")
@dlt.table
def dim_customer():
return spark.read.table("dim.dim_customer_seed")
dlt.apply_changes(
target = "dim_customer",
source = "customer_changes",
keys = ["customer_id"],
sequence_by = col("change_ts"),
stored_as_scd_type = "2",
track_history_columns = ["name", "address"]
)
With stored_as_scd_type = "2", the target table gets two extra columns, __START_AT and __END_AT, populated from the sequence_by column. When a change arrives for an existing key and one of the tracked columns differs, the current row is closed by setting its __END_AT to the new event's sequence value, and a new row is inserted with __END_AT null. The current version of each key is therefore the row where __END_AT IS NULL, and the version valid at any instant T is the row where __START_AT <= T and __END_AT is null or greater than T.
Because events are ordered by sequence_by rather than by arrival time, late or out-of-order events are placed into the correct position in history. That is the main thing hand-written MERGE logic for SCD2 usually gets wrong.
In a typical medallion pipeline the CDC feed (from Debezium, a database's change data feed, or a vendor such as Fivetran) lands in a bronze table, is cleaned into a silver streaming table with expectations that drop or quarantine malformed records, and is then applied to a gold dimension. The target of apply_changes must be declared with dlt.create_streaming_table; it is maintained entirely by the apply flow. Databricks has since renamed DLT to Lakeflow Declarative Pipelines and introduced create_auto_cdc_flow as the newer name for the same API; existing apply_changes code continues to work.
import dlt
from pyspark.sql.functions import col, expr
@dlt.view
@dlt.expect_or_drop("valid_key", "customer_id IS NOT NULL")
def customer_changes_clean():
return spark.readStream.table("silver.customer_changes")
dlt.create_streaming_table("dim_customer")
dlt.apply_changes(
target="dim_customer",
source="customer_changes_clean",
keys=["customer_id"],
sequence_by=col("change_ts"),
apply_as_deletes=expr("op = 'DELETE'"),
except_column_list=["op"],
stored_as_scd_type=2,
track_history_column_list=["name", "address"],
)
@dlt.table(comment="Current row per customer")
def dim_customer_current():
return dlt.read("dim_customer").where("__END_AT IS NULL")
track_history_column_list (or track_history_except_column_list) restricts which columns create new versions; changes to other columns update the current row in place. apply_as_deletes closes the current row when a delete event arrives.
| Approach | History | Rows per key | Use when |
|---|---|---|---|
| SCD Type 1 | None, overwrite | 1 | Corrections, attributes nobody reports on historically |
| SCD Type 2 | Full, row per version | Many | Point-in-time reporting: revenue by the customer's region at the time of sale |
| Delta time travel | Table-level versions, limited by retention | 1 | Auditing and recovery, not modelled history |
| Periodic snapshots | Full copy per period | 1 per period | Simple sources with no change feed |
sequence_by. Two events for the same key with identical sequence values have no defined order; use a struct of timestamp plus a log sequence number to break ties.last_login, creates a new version for each change and bloats the table.