Home / Databricks on Azure

Databricks on AzureHow-toAdvanced

Delta change data feed on Databricks: incremental silver-to-gold propagation without full rescans

Enable change data feed on Delta tables, read inserts, updates and deletes by version or as a stream, apply them downstream with MERGE, handle retention and schema changes, and know when CDF is the wrong tool.

Deepak Bhardwaj4 min read

Applies toAzure Databricks, Delta Lake change data feed (Databricks Runtime 12.2 LTS and later recommended), Structured Streaming, Unity Catalog

Rebuilding a gold table from a 5 TB silver table every hour, because a few thousand rows changed, is expensive. Change data feed (CDF) records row-level changes on a Delta table so downstream jobs process only what changed, including updates and deletes.

Enable it

ALTER TABLE sales_prod.silver.customers SET TBLPROPERTIES (delta.enableChangeDataFeed = true);
-- or at creation
CREATE TABLE sales_prod.silver.orders (...) TBLPROPERTIES (delta.enableChangeDataFeed = true);

CDF only records changes after it is enabled. Note the version at which you enabled it:

DESCRIBE HISTORY sales_prod.silver.customers LIMIT 5;

What a change looks like

Reading CDF adds three columns:

Column Values
_change_type insert, update_preimage, update_postimage, delete
_commit_version Delta version of the change
_commit_timestamp Commit time
SELECT customer_id, email, _change_type, _commit_version
FROM table_changes('sales_prod.silver.customers', 120, 125)
ORDER BY _commit_version;

Batch reads by version

changes = (spark.read
    .option("readChangeFeed", "true")
    .option("startingVersion", last_processed + 1)
    .option("endingVersion", current_version)
    .table("sales_prod.silver.customers"))

Store last_processed in a small control table, and update it in the same job run after the downstream write succeeds.

Streaming reads (simplest)

(spark.readStream
    .option("readChangeFeed", "true")
    .option("startingVersion", 125)       # only on first start; afterwards the checkpoint decides
    .table("sales_prod.silver.customers")
 .writeStream
    .foreachBatch(apply_changes)
    .option("checkpointLocation", "/Volumes/sales_prod/_checkpoints/gold_customers")
    .trigger(availableNow=True)
    .start())

availableNow processes everything new and stops, which fits scheduled jobs.

Apply changes with MERGE

A version can contain several changes per key; keep only the latest per key, and drop pre-images:

from pyspark.sql import Window, functions as F

def apply_changes(batch_df, batch_id):
    w = Window.partitionBy("customer_id").orderBy(F.col("_commit_version").desc())
    latest = (batch_df.filter("_change_type != 'update_preimage'")
              .withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn"))
    latest.createOrReplaceTempView("changes")
    batch_df.sparkSession.sql("""
      MERGE INTO sales_prod.gold.dim_customer t
      USING changes s ON t.customer_id = s.customer_id
      WHEN MATCHED AND s._change_type = 'delete' THEN DELETE
      WHEN MATCHED THEN UPDATE SET t.email = s.email, t.country = s.country, t.updated_at = s._commit_timestamp
      WHEN NOT MATCHED AND s._change_type != 'delete' THEN
        INSERT (customer_id, email, country, updated_at) VALUES (s.customer_id, s.email, s.country, s._commit_timestamp)
    """)

Warning

Without the dedupe step, MERGE fails with "multiple source rows matched" whenever a key changed twice in the batch. Without the preimage filter, updates are applied twice in the wrong order.

For aggregated gold tables (sums by day), recompute only the affected keys: collect the distinct dates or customers from the changes, then rebuild those slices with replaceWhere or MERGE.

Retention and gaps

CDF data lives in the table's _change_data folder and is removed by VACUUM along with old files, following the table's retention. If a downstream job is down longer than retention:

DeltaFileNotFoundException / "change data for version X is not available"

Recovery: do a one-time full rebuild of the downstream table, then restart CDF from the current version. Keep delta.deletedFileRetentionDuration (and delta.logRetentionDuration) longer than your worst-case outage.

Schema changes

  • Adding columns: CDF continues; new columns appear in later versions.
  • Column renames/drops with column mapping: streaming reads of CDF may fail across non-additive schema changes. Restart with a new checkpoint from the version after the change, or read in batch mode around it. Check the current documentation for your runtime's support.

Operations that look like big changes

Operation CDF effect
OPTIMIZE, ZORDER, liquid clustering No change rows (data not logically changed)
UPDATE on many rows Pre and post images for each
Full INSERT OVERWRITE Delete + insert for every row: downstream reprocesses everything
TRUNCATE / DELETE without filter Delete rows for all data (when recorded)

Avoid overwriting CDF-enabled tables in full; use MERGE or replaceWhere on a slice.

When not to use CDF

  • Append-only tables: a plain streaming read of the Delta table is simpler.
  • Tables you fully rebuild each run: changes are the whole table anyway.
  • Very small tables: full rebuild is cheaper than the bookkeeping.

Prevent it next time

  • Control table or checkpoint per consumer, monitored for lag in versions.
  • Retention settings documented on every CDF table, longer than the longest planned outage.
  • Upstream writers use MERGE, not overwrite, when consumers rely on CDF.