What you will be able to do
- Explain why streaming from a table's change data feed is the recommended way to propagate updates and deletes downstream
- Compare change data feed with skipChangeCommits, full refresh and materialized views
- Configure a readChangeFeed stream, including the initial snapshot, startingVersion and rate limits
- Plan for change data feed retention and recover a CDF stream after a corrupted checkpoint
1.Why plain streaming breaks on updates and deletes
A plain Delta streaming source only works on append-only tables. Once the source table takes UPDATE, DELETE, MERGE INTO or OVERWRITE, you have four options, and only one of them carries every change type downstream.
skipChangeCommits keeps the stream alive by ignoring every file-changing commit, so updates and deletes never reach the target. The older ignoreChanges re-emits rewritten files, which means duplicates, and still drops deletes. A full refresh is simple but you pay for the whole dataset every time. Materialized views propagate changes for you, but with higher latency, and they exist only in Lakeflow pipelines and Databricks SQL. The change data feed carries inserts, updates and deletes downstream as explicit events. The cost is that you write logic to handle each _change_type.
| Approach | Propagates updates/deletes? | Main cost |
|---|---|---|
| skipChangeCommits | No; only processes appends | Changes need separate logic |
| Full refresh | Yes, by reprocessing everything | Expensive for large datasets; all downstream tables reprocess |
| Change data feed | Yes; inserts, updates, and deletes | More complex logic per change type |
| Materialized views | Yes; automatic change propagation | Higher latency; Lakeflow pipelines and Databricks SQL only |
Checkpoint 1 of 6· Check yourself
A GDPR process deletes and updates rows in a source Delta table. A downstream silver table must reflect those deletes incrementally, without reprocessing the full source. Which approach fits?
skipChangeCommits ignores change commits entirely, and ignoreChanges never sends deletes downstream. Restarting from scratch is a full refresh. Only the change data feed carries deletes as row-level events.
“Databricks recommends streaming from the CDC feed of a Delta Lake table rather than directly from the table whenever possible.”Source: docs.databricks.com
2.Configuring a readChangeFeed stream
Streaming is the recommended way to consume a change feed, and it is the only way Databricks tracks which versions you have already processed. With a batch table_changes call, keeping track of versions is your job. A stream's checkpoint records it for you.
(spark.readStream
.option("readChangeFeed", "true")
.table("myTable")
)That initial snapshot is right for a new pipeline whose target is empty. If the target already holds everything up to some version, pass startingVersion (or a timestamp) so the stream doesn't load the whole source state again as INSERT events.
The usual rate limits, maxFilesPerTrigger and maxBytesPerTrigger, plus excludeRegex, work on change data. After the initial snapshot, limits apply to whole commits. A micro-batch takes all of a commit or defers it to the next batch, so a large MERGE is never split across batches.
Checkpoint 2 of 6· Check yourself
A target table already contains every change from a source up to version 500. You start a new readChangeFeed stream into it. Which setting avoids reprocessing the source's full state?
Without a starting version the stream first emits the whole current table as inserts. Starting just after the last processed version skips that.
“specify a starting version to avoid processing the source table state as INSERT events.”Source: docs.databricks.com
Checkpoint 3 of 6· Exam question
A downstream job must audit exactly the row-level changes that occurred between Delta version 12 and version 18, inclusive, on `analytics.orders`, which has Change Data Feed enabled. Which SQL statement retrieves precisely this range of changes?
Correct answer: A — `SELECT * FROM table_changes('analytics.orders', 12, 18)`, which passes the starting and ending version bounds to the change feed table-valued function and returns every row-level change recorded between those two committed versions.
- A. The `table_changes` function is the documented way to read Change Data Feed output in SQL, and supplying a starting and ending version bounds the result to exactly the commits in that inclusive range. This is the most direct and correct way to satisfy the audit requirement.
- B. Time-travel snapshots via `VERSION AS OF` return the complete state of the table at a single version, not the discrete change events between two versions. Diffing two full snapshots client-side would be both incorrect for updates that were later overwritten and far more expensive than reading the change feed directly.
- C. `TIMESTAMP AS OF` requires a timestamp literal rather than an integer version number, so this statement is malformed as written. It also never invokes the change feed mechanism, so the resulting rows would lack the `_change_type` and `_commit_version` columns needed for the audit.
- D. `DESCRIBE HISTORY` is useful for inspecting operation-level metadata like who ran a `MERGE` and when, but it does not return the underlying row data that was inserted, updated, or deleted. It cannot substitute for reading the actual change feed content.
Sources2
3.Retention, permanent history, and checkpoint recovery
The change feed is not an archive. It only holds changes made after it was enabled, and its records are transient. When table versions are cleaned up, their change data goes with them. With legacy CDF, VACUUM deletes the change data files, and changes computed from the transaction log follow the checkpoint retention policy. Once a version is gone you can't read its changes any more. If you need a permanent change history, read the feed incrementally and write it to a table of its own.
For that kind of audit or replay job you rarely need an always-on cluster. An availableNow trigger processes everything available and then stops, so it can run on a schedule:
(spark.readStream
.option("readChangeFeed", "true")
.table("source_table")
.writeStream
.option("checkpointLocation", <checkpoint-path>)
.trigger(availableNow=True)
.toTable("target_table")
)Start a new stream with startingVersion 76 and a new checkpoint location, writing to the same target table. This only works while version 76 is still in the table history. Managed tables eventually clean up old versions, so any starting version you specify will become unavailable at some point.
Checkpoint 4 of 6· Fill the gap
Complete the recovery stream so it picks up right after the last processed version
(spark.readStream
.option("readChangeFeed", "true")
.option(" ? ", 76)
.table("source_table")
.writeStream
.option("checkpointLocation", "<new-checkpoint-path>")
.toTable("target_table")
)startingVersion 76 resumes right after version 75, the last one the target processed. endingVersion only bounds batch reads, and the other two control batch size and idempotent writes.
Source: docs.databricks.comCheckpoint 5 of 6· Check yourself
Compliance wants every change to a table kept for seven years. Which design meets that?
Change feed records only last for the retention window. A permanent history means copying them into a table you control.
“Records in the change data feed are transient and only accessible for a specified retention window.”Source: docs.databricks.com
Checkpoint 6 of 6· Exam question
A team reads Change Data Feed output from `finance.ledger` and writes Python logic that branches on the `_change_type` column to route rows to an audit table. Which statements about the `_change_type` values are correct?(Select 3)
Correct answers: A, B, E — Each source-table `UPDATE` produces two rows tagged `update_preimage` and `update_postimage`, letting the routing logic compare a record's value before and after the change within the same commit.; A deleted row appears exactly once with `_change_type` set to `delete`, carrying the row's last known column values, so the routing logic does not need to reconstruct the deleted content from elsewhere.; Newly inserted rows are tagged with `_change_type` equal to `insert` and carry only the new column values, since there is no prior version of the row to compare against.
- A. Updates are represented as a paired pre-image and post-image row so consumers can see both the old and new state of the changed record. This pairing is exactly what lets downstream logic compute field-level diffs for auditing.
- B. A delete is recorded as a single row carrying the last known values of the deleted record, tagged accordingly. This means the routing logic already has everything it needs to log what was deleted without a separate lookup.
- C. The four tag values describe what happened to a row, but they are not themselves valid `MERGE` clause syntax and require the calling code to translate them into the appropriate insert, update, or delete action. The premise that branching logic becomes unnecessary is incorrect.
- D. There is no `upsert` value in the change feed; a `MERGE` that inserts some rows and updates others produces ordinary `insert` and paired `update_preimage`/`update_postimage` rows for the respective operations. Treating `upsert` as a real tag would cause the routing logic to silently miss those rows.
- E. New rows have nothing to compare against, so they are tagged as a plain insert carrying only the new values, unlike updates which carry a before-and-after pair. This asymmetry is expected and by design.
- F. Commit version and commit timestamp metadata are populated for every change event, not only deletes, because they describe the transaction that produced the row rather than the row's own change type. Excluding inserts and updates from that metadata would break the ability to order any change chronologically.
Sources2
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.Once CDF is enabled, the table keeps a permanent, queryable record of every change ever made.Why is that wrong?
The feed only covers changes since enablement, and versions expire. For permanent history, write the feed into its own table.
Covered in Retention, permanent history, and checkpoint recovery
2.skipChangeCommits lets a stream pass upstream updates and deletes through to the target.Why is that wrong?
skipChangeCommits drops every commit that rewrites files, so only appends get through. Carrying changes downstream needs CDF or separate logic.
Covered in Why plain streaming breaks on updates and deletes
3.A new readChangeFeed stream only emits commits made after it starts.Why is that wrong?
Without a starting version, the first batch is the whole current table emitted as INSERT records. Set startingVersion when the target already has that state.
Covered in Configuring a readChangeFeed stream
Practise it for real
Create a CDF-enabled table, make inserts, an update and a delete, and see each one appear as row-level change events.
1.Run: CREATE TABLE myschema.t(c1 INT, c2 STRING) TBLPROPERTIES(delta.enableChangeDataFeed=true);
Why: Legacy CDF has to be switched on per table, here at creation time.
You should see: DESCRIBE HISTORY shows version 0 as CREATE TABLE with delta.enableChangeDataFeed set to true in its properties.
2.Run the two INSERTs, then UPDATE myschema.t SET c2 = upper(c2) WHERE c1 < 3; then DELETE FROM myschema.t WHERE c1 = 3;
Why: This gives you one commit of each kind: append, update and delete.
You should see: DESCRIBE HISTORY shows versions 1–4: WRITE, WRITE, UPDATE, DELETE.
3.Run: SELECT * FROM table_changes('
myschema.t', 2);Why: Reads every change from version 2 (inclusive) to the latest.
You should see: One insert at version 2, two update_preimage/update_postimage pairs at version 3, and one delete at version 4.
4.Start spark.readStream.option("readChangeFeed", "true").table(...) on the table with trigger(availableNow=True), writing to a new target with its own checkpoint.
Why: Shows what a stream emits the first time it runs without a starting version.
You should see: The target receives the table's current rows as insert records.
Stuck? Get a nudge
Run one more UPDATE and rerun the availableNow stream. Only the new pre/post pair should arrive, because the checkpoint remembers where it stopped.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“This is the most robust approach because your code explicitly handles every type of change event.”
↩︎ Why plain streaming breaks on updates and deletes“skipChangeCommits disregards file-changing operations entirely.”
↩︎ Why plain streaming breaks on updates and deletes“Unchanged rows are often emitted alongside new rows, so downstream consumers must be able to handle duplicates. Deletes are not propagated downstream.”
↩︎ Why plain streaming breaks on updates and deletes“To reflect changes in stream source tables, you must implement separate logic to propagate these changes.”
↩︎ Exam trap 2“Structured Streaming only accepts append inputs and throws an exception if any modifications occur on the source Delta Lake table.”
↩︎ Prediction“Databricks recommends streaming from the CDC feed of a Delta Lake table rather than directly from the table whenever possible.”
↩︎ Checkpoint - 2.
“For CDC processing with SCD type 1 or type 2 tables, see The AUTO CDC APIs”
↩︎ Why plain streaming breaks on updates and deletes“You must use Structured Streaming for Databricks to automatically track versions for your table's change data feed.”
↩︎ Configuring a readChangeFeed stream“Databricks supports rate limits (maxFilesPerTrigger, maxBytesPerTrigger) and excludeRegex when reading change data.”
↩︎ Configuring a readChangeFeed stream“Either the current batch includes the entire commit, or the current batch defers the commit to the next batch.”
↩︎ Configuring a readChangeFeed stream“It only records changes that occur after change data feed was enabled.”
↩︎ Retention, permanent history, and checkpoint recovery“The VACUUM command deletes change data files, and changes from the transaction log use checkpoint retention policy.”
↩︎ Retention, permanent history, and checkpoint recovery“use incremental logic to write records from the change data feed to a new table.”
↩︎ Retention, permanent history, and checkpoint recovery“Because managed tables clean up historic versions automatically, all specified starting versions are eventually deleted.”
↩︎ Retention, permanent history, and checkpoint recovery“When you define the write stream to the existing target table, you must specify a new checkpoint location”
↩︎ Retention, permanent history, and checkpoint recovery“A change data feed is not intended to serve as a permanent record of all changes to a table.”
↩︎ Exam trap 1“records all existing records in the table as INSERT operations when the stream first starts.”
↩︎ Exam trap 3“change data feed returns the latest snapshot of the table as INSERT records and then returns future changes as change data.”
↩︎ Prediction“specify a starting version to avoid processing the source table state as INSERT events.”
↩︎ Checkpoint“Records in the change data feed are transient and only accessible for a specified retention window.”
↩︎ Checkpoint