Bitemporal AUTO CDC
Bitemporal AUTO CDC is a change data capture (CDC) storage type that records the history of each record across two timelines: when a change was true in the real world, and when your system learned about it. Use it when you need to answer both "What was true on a given date?" and "What did the system believe on a given date?", for example, for audits, regulatory reporting, or reproducing a past decision.
This page explains how bitemporal tables work, walks through a complete example, and shows how to process snapshot sources. For a conceptual comparison with slowly changing dimension (SCD) Type 1 and SCD Type 2, see Bitemporal: History across business time and system time. For the other AUTO CDC storage types, see The AUTO CDC APIs: Simplify change data capture with pipelines.
How bitemporal tables work
SCD Type 1 and SCD Type 2 tables track changes across a single timeline, the order defined by the SEQUENCE BY column. A bitemporal table tracks two timelines:
- Business time (also called valid time): when a change took effect in the real world. For example, the date an employee's promotion became effective. You specify it with
SEQUENCE BY. - System time (also called transaction time): when the system recorded the change. For example, the time the HR system sent the promotion to your pipeline. You specify it with
SYSTEM SEQUENCE BY.
When a change arrives late, out of order, or as a correction, the pipeline doesn't overwrite history. Instead, it closes the rows that the system previously believed, at the new system time, and adds rows that reflect the corrected business history. You can reconstruct both the latest view of history and the view the system held at any earlier time.
The target table contains the columns from the source plus the following four columns:
Column | Description |
|---|---|
| The business time at which this version of the record became valid. |
| The business time at which this version of the record stopped being valid. |
| The system time at which the system started to believe this row. |
| The system time at which the system stopped believing this row, because a later change replaced it. |
Rows where __SYSTEM_END_AT is null represent what the system currently believes. Rows with a non-null __SYSTEM_END_AT are earlier beliefs that the pipeline retains for auditing.
Requirements
- Your pipeline must meet the requirements for the AUTO CDC APIs.
- The source must provide a business time column for
SEQUENCE BYand a system time column forSYSTEM SEQUENCE BY. Both columns must be sortable data types, and they can have different data types.
For features that bitemporal tables don't support, see AUTO CDC limitations.
Example: Track employee roles with bitemporal AUTO CDC
This walkthrough tracks employee roles from an HR system. Each change event has an effectiveDate column, which is the business time when the role change took effect, and a recordedAt column, which is the system time when the HR system sent the event. You create sample data, run a pipeline, and then add an in-order update, an out-of-order update, and a correction, running the pipeline after each step to see how the target table changes.
Step 1: Create sample data
Run the following statements to create the source table with an initial load of two employees. This code is not intended to be run as part of a pipeline definition. Run it from the exploration folder of your pipeline, rather than the transformations folder.
CREATE SCHEMA IF NOT EXISTS main.cdc_tutorial;
CREATE OR REPLACE TABLE main.cdc_tutorial.employees_cdf (
employeeId INT,
name STRING,
role STRING,
operation STRING,
effectiveDate DATE,
recordedAt TIMESTAMP
);
INSERT INTO main.cdc_tutorial.employees_cdf VALUES
-- Initial load. Dana's role is recorded incorrectly. Step 5 corrects it.
(1, 'Chris', 'Owner', 'INSERT', DATE'2025-01-01', TIMESTAMP'2025-01-02 09:00:00'),
(2, 'Dana', 'Engineer', 'INSERT', DATE'2025-01-01', TIMESTAMP'2025-01-02 09:00:00');
Step 2: Create and run the pipeline
The following code reads the change data feed and applies the changes to a bitemporal streaming table. SEQUENCE BY sets the business time and SYSTEM SEQUENCE BY sets the system time. You need a pipeline to run this code. See Develop and debug ETL pipelines with the Lakeflow Pipelines Editor.
- Python
- SQL
from pyspark import pipelines as dp
from pyspark.sql.functions import expr
@dp.view
def employees():
return spark.readStream.table("main.cdc_tutorial.employees_cdf")
dp.create_streaming_table("employees_bitemporal")
dp.create_auto_cdc_flow(
target = "employees_bitemporal",
source = "employees",
keys = ["employeeId"],
sequence_by = "effectiveDate",
system_sequence_by = "recordedAt",
apply_as_deletes = expr("operation = 'DELETE'"),
except_column_list = ["operation"],
stored_as_scd_type = "bitemporal"
)
CREATE OR REFRESH STREAMING TABLE employees_bitemporal;
CREATE FLOW apply_cdc AS AUTO CDC INTO
employees_bitemporal
FROM
stream(main.cdc_tutorial.employees_cdf)
KEYS
(employeeId)
APPLY AS DELETE WHEN
operation = "DELETE"
SEQUENCE BY
effectiveDate
SYSTEM SEQUENCE BY
recordedAt
COLUMNS * EXCEPT
(operation)
STORED AS
BITEMPORAL;
Run the pipeline. The tables in this walkthrough show the employeeId, name, and role columns and the four bitemporal columns. The target table also contains the effectiveDate and recordedAt source columns. After the first run, the target table contains the following records:
employeeId | name | role | __START_AT | __END_AT | __SYSTEM_START_AT | __SYSTEM_END_AT |
|---|---|---|---|---|---|---|
1 | Chris | Owner | 2025-01-01 | null | 2025-01-02 09:00:00 | null |
2 | Dana | Engineer | 2025-01-01 | null | 2025-01-02 09:00:00 | null |
Both roles are valid from January 1 with no known end. The system learned about them on January 2.
Step 3: Add an in-order update
Chris is promoted to Manager, effective March 1. The HR system sends the change the same day. Add the event to the source table:
INSERT INTO main.cdc_tutorial.employees_cdf VALUES
(1, 'Chris', 'Manager', 'UPDATE', DATE'2025-03-01', TIMESTAMP'2025-03-01 09:00:00');
Run the pipeline again. The target table contains the following records for Chris. Dana's record doesn't change.
employeeId | name | role | __START_AT | __END_AT | __SYSTEM_START_AT | __SYSTEM_END_AT |
|---|---|---|---|---|---|---|
1 | Chris | Owner | 2025-01-01 | null | 2025-01-02 09:00:00 | 2025-03-01 09:00:00 |
1 | Chris | Owner | 2025-01-01 | 2025-03-01 | 2025-03-01 09:00:00 | null |
1 | Chris | Manager | 2025-03-01 | null | 2025-03-01 09:00:00 | null |
The pipeline doesn't edit the original Owner row. It closes that row in system time at March 1, 09:00, which records that the system believed "Owner with no end date" until then. It adds a row with the updated belief, Owner from January 1 until March 1, and a row for Manager from March 1 with no known end.
Step 4: Add an out-of-order update
On March 15, the HR system sends an event that it missed earlier. Chris was a Team Lead between the Owner and Manager roles, effective February 1. This event arrives after the March 1 promotion in system time, but its business time is earlier. Add the event to the source table:
INSERT INTO main.cdc_tutorial.employees_cdf VALUES
(1, 'Chris', 'Team Lead', 'UPDATE', DATE'2025-02-01', TIMESTAMP'2025-03-15 09:00:00');
Run the pipeline again. The target table contains the following records for Chris:
employeeId | name | role | __START_AT | __END_AT | __SYSTEM_START_AT | __SYSTEM_END_AT |
|---|---|---|---|---|---|---|
1 | Chris | Owner | 2025-01-01 | null | 2025-01-02 09:00:00 | 2025-03-01 09:00:00 |
1 | Chris | Owner | 2025-01-01 | 2025-03-01 | 2025-03-01 09:00:00 | 2025-03-15 09:00:00 |
1 | Chris | Owner | 2025-01-01 | 2025-02-01 | 2025-03-15 09:00:00 | null |
1 | Chris | Team Lead | 2025-02-01 | 2025-03-01 | 2025-03-15 09:00:00 | null |
1 | Chris | Manager | 2025-03-01 | null | 2025-03-01 09:00:00 | null |
The pipeline inserts Team Lead into the business-time history instead of appending it to the end. The belief that Chris was Owner until March 1 is closed at March 15, 09:00. From that time, the system believes that Chris was Owner until February 1 and Team Lead from February 1 until March 1. The Manager row doesn't change, because the late event doesn't affect it.
Step 5: Correct an erroneous value
Dana's role was recorded incorrectly in the initial load. Dana has been an Analyst since January 1, not an Engineer. To correct a value, add a record with the corrected value, the same business time as the erroneous record, and a later system time:
INSERT INTO main.cdc_tutorial.employees_cdf VALUES
(2, 'Dana', 'Analyst', 'UPDATE', DATE'2025-01-01', TIMESTAMP'2025-04-01 09:00:00');
Run the pipeline again. The target table contains the following records for Dana. Chris's records don't change.
employeeId | name | role | __START_AT | __END_AT | __SYSTEM_START_AT | __SYSTEM_END_AT |
|---|---|---|---|---|---|---|
2 | Dana | Engineer | 2025-01-01 | null | 2025-01-02 09:00:00 | 2025-04-01 09:00:00 |
2 | Dana | Analyst | 2025-01-01 | null | 2025-04-01 09:00:00 | null |
Both rows have the same business-time range. The erroneous row's system-time range ends at April 1, 09:00, when the corrected row's system-time range starts. Queries that filter on __SYSTEM_END_AT IS NULL return only the corrected value, but the table still records that the system believed Dana was an Engineer from January 2 until April 1.
Delete records
To delete a record, send an event that matches the APPLY AS DELETE WHEN condition, with the business time when the record stopped existing and the system time when the system learned about it. The pipeline closes the record's business-time range at the delete's business time, effective from the delete's system time, and retains the earlier belief.
For example, if the source sends (1, null, null, 'DELETE', DATE'2025-06-30', TIMESTAMP'2025-06-30 17:00:00'), the Manager row for Chris is closed at system time 2025-06-30 17:00:00. A new Manager row with a business-time range from 2025-03-01 to 2025-06-30 is added from that system time.
Query a bitemporal table
A bitemporal table has two validity ranges per row, so filter on both timelines. The following queries use the employees_bitemporal table after step 5 of the walkthrough.
Query the current state
The following query returns the current state, as the system currently believes it:
SELECT employeeId, name, role
FROM employees_bitemporal
WHERE __END_AT IS NULL AND __SYSTEM_END_AT IS NULL;
This query returns Chris as Manager and Dana as Analyst.
Query business-time history
The following query returns the business-time history, as the system currently believes it:
SELECT employeeId, name, role, __START_AT, __END_AT
FROM employees_bitemporal
WHERE __SYSTEM_END_AT IS NULL
ORDER BY employeeId, __START_AT;
This query returns the corrected history: Chris as Owner from 2025-01-01 to 2025-02-01, Team Lead from 2025-02-01 to 2025-03-01, and Manager from 2025-03-01, and Dana as Analyst from 2025-01-01.
Query a past belief about a past business time
The following query asks what role each employee had on February 15, according to what the system knew on March 10. The business time is 2025-02-15 and the system time is 2025-03-10 00:00:00:
SELECT employeeId, name, role
FROM employees_bitemporal
WHERE __START_AT <= DATE'2025-02-15'
AND (__END_AT > DATE'2025-02-15' OR __END_AT IS NULL)
AND __SYSTEM_START_AT <= TIMESTAMP'2025-03-10 00:00:00'
AND (__SYSTEM_END_AT > TIMESTAMP'2025-03-10 00:00:00' OR __SYSTEM_END_AT IS NULL);
This query returns Chris as Owner and Dana as Engineer, because on March 10 the system had not yet received the Team Lead event or Dana's correction. To see what the system believes today, change the system time to the current time. The query then returns Chris as Team Lead and Dana as Analyst.
Process snapshots with bitemporal AUTO CDC
Bitemporal tables don't support AUTO CDC FROM SNAPSHOT. If your source only provides periodic full snapshots, derive a change feed from the snapshots and process that change feed with bitemporal AUTO CDC.
If you only need one timeline, use AUTO CDC FROM SNAPSHOT with SCD Type 2 instead. See AUTO CDC FROM SNAPSHOT examples.
Option 1: Merge snapshots into a table with change data feed enabled
If you control how snapshots land in Databricks, merge each snapshot into a Delta table that has change data feed enabled. The MERGE statement writes only the rows that were inserted, updated, or removed, so the table's change data feed is a CDC feed that AUTO CDC can process.
-
Create the table, and enable change data feed. The
isDeletedcolumn marks records that are absent from the latest snapshot:SQLCREATE TABLE main.cdc_tutorial.employees_current (employeeId INT,name STRING,role STRING,snapshotDate DATE,isDeleted BOOLEAN)TBLPROPERTIES (delta.enableChangeDataFeed = true); -
Merge each new snapshot into the table. The following example reads a table named
main.cdc_tutorial.employees_snapshotsthat accumulates snapshots. Each row has asnapshotDatecolumn with the business date that its snapshot represents, and eachemployeeIdcan appear only one time per snapshot. The script merges each snapshot that is newer than the latest merged snapshot, one at a time, in ascendingsnapshotDateorder:SQLBEGINFOR snap ASSELECT DISTINCT snapshotDateFROM main.cdc_tutorial.employees_snapshotsWHERE snapshotDate > (SELECT COALESCE(MAX(snapshotDate), DATE'0001-01-01')FROM main.cdc_tutorial.employees_current)ORDER BY snapshotDateDOMERGE INTO main.cdc_tutorial.employees_current AS tUSING (SELECT * FROM main.cdc_tutorial.employees_snapshotsWHERE snapshotDate = snap.snapshotDate) AS sON t.employeeId = s.employeeIdWHEN MATCHED AND (t.isDeleted OR NOT (t.name <=> s.name AND t.role <=> s.role)) THENUPDATE SET name = s.name, role = s.role, snapshotDate = s.snapshotDate, isDeleted = falseWHEN NOT MATCHED THENINSERT (employeeId, name, role, snapshotDate, isDeleted)VALUES (s.employeeId, s.name, s.role, s.snapshotDate, false)WHEN NOT MATCHED BY SOURCE AND NOT t.isDeleted THENUPDATE SET snapshotDate = snap.snapshotDate, isDeleted = true;END FOR;END;The script uses SQL scripting, which requires Databricks SQL or Databricks Runtime 16.3 and above. It behaves as follows:
- Each snapshot is merged in its own commit, so each snapshot gets its own
_commit_timestamp. If a run is skipped, the next run merges every pending snapshot in order. - The merge marks removed records with
isDeleted = trueinstead of deleting them. As a result, each removal appears in the change data feed with the date of the snapshot that removed it, and that date becomes the business time of the delete. - The latest merged
snapshotDateinemployees_currentrecords progress, and it is written in the same commit as the data. Running the script again without new snapshots doesn't add change data feed rows. - Snapshots older than the latest merged snapshot are skipped, because merging one would move the current state backward.
- Each snapshot is merged in its own commit, so each snapshot gets its own
-
In your pipeline, read the change data feed and apply it with bitemporal
AUTO CDC. The_commit_timestampmetadata column, which records when the merge committed, is the system time. Skipupdate_preimagerows, which contain the values before each update. Set the starting version to0. Otherwise, the first pipeline run reads the table's current contents as inserts, and every earlier merge collapses into one system time.- Python
- SQL
Pythonfrom pyspark import pipelines as dpfrom pyspark.sql.functions import col, expr@dp.viewdef employees_changes():return (spark.readStream.option("readChangeFeed", "true").option("startingVersion", 0).table("main.cdc_tutorial.employees_current").filter(col("_change_type") != "update_preimage"))dp.create_streaming_table("employees_bitemporal_from_snapshots")dp.create_auto_cdc_flow(target = "employees_bitemporal_from_snapshots",source = "employees_changes",keys = ["employeeId"],sequence_by = "snapshotDate",system_sequence_by = "_commit_timestamp",apply_as_deletes = expr("isDeleted"),except_column_list = ["isDeleted", "_change_type", "_commit_version"],stored_as_scd_type = "bitemporal")SQLCREATE OR REFRESH STREAMING TABLE employees_bitemporal_from_snapshots;CREATE TEMPORARY VIEW employees_changes ASSELECT *FROM STREAM main.cdc_tutorial.employees_current WITH (readChangeFeed = true, startingVersion = 0)WHERE _change_type != 'update_preimage';CREATE FLOW apply_snapshot_changes AS AUTO CDC INTOemployees_bitemporal_from_snapshotsFROMstream(employees_changes)KEYS(employeeId)APPLY AS DELETE WHENisDeletedSEQUENCE BYsnapshotDateSYSTEM SEQUENCE BY_commit_timestampCOLUMNS * EXCEPT(isDeleted, _change_type, _commit_version)STORED ASBITEMPORAL;
Option 2: Compare consecutive snapshots in a job
If the snapshots are stored together in one table, with a column that identifies each snapshot, compare consecutive snapshots in a job task that runs before the pipeline, and append the differences to a change table. The pipeline then reads the change table as the source for bitemporal AUTO CDC.
-
Create the change table:
SQLCREATE TABLE main.cdc_tutorial.employees_snapshot_changes (employeeId INT,name STRING,role STRING,operation STRING,snapshotDate DATE,recordedAt TIMESTAMP); -
In a job task, compare each new snapshot with the snapshot before it. The following example reads a table named
main.cdc_tutorial.employees_snapshots, which has asnapshotDatecolumn. Each snapshot's date is the business time, and the time the job runs is the system time:SQLINSERT INTO main.cdc_tutorial.employees_snapshot_changesWITH snapshot_pairs AS (SELECT snapshotDate, LAG(snapshotDate) OVER (ORDER BY snapshotDate) AS prevSnapshotDateFROM (SELECT DISTINCT snapshotDate FROM main.cdc_tutorial.employees_snapshots)),pending AS (SELECT * FROM snapshot_pairsWHERE snapshotDate > (SELECT COALESCE(MAX(snapshotDate), DATE'0001-01-01')FROM main.cdc_tutorial.employees_snapshot_changes)),curr AS (SELECT p.snapshotDate, s.employeeId, s.name, s.roleFROM pending pJOIN main.cdc_tutorial.employees_snapshots s ON s.snapshotDate = p.snapshotDate),prev AS (SELECT p.snapshotDate, s.employeeId, s.name, s.roleFROM pending pJOIN main.cdc_tutorial.employees_snapshots s ON s.snapshotDate = p.prevSnapshotDate)SELECTCOALESCE(curr.employeeId, prev.employeeId) AS employeeId,curr.name,curr.role,CASEWHEN prev.employeeId IS NULL THEN 'INSERT'WHEN curr.employeeId IS NULL THEN 'DELETE'ELSE 'UPDATE'END AS operation,COALESCE(curr.snapshotDate, prev.snapshotDate) AS snapshotDate,current_timestamp() AS recordedAtFROM currFULL OUTER JOIN prevON curr.snapshotDate = prev.snapshotDate AND curr.employeeId = prev.employeeIdWHERE prev.employeeId IS NULLOR curr.employeeId IS NULLOR NOT (curr.name <=> prev.name AND curr.role <=> prev.role);The statement behaves as follows:
- Every snapshot that is newer than the latest processed snapshot is compared with the snapshot just before it. If a run is skipped, or several snapshots arrive together, the next run compares every pair in order. The first snapshot has no earlier snapshot, so all of its rows become
INSERTevents. - The latest
snapshotDatein the change table records progress, and the whole run is oneINSERT, so the progress and the change rows are committed together. Running the statement again without new snapshots inserts nothing. - Snapshots compared in the same run share one
recordedAtvalue, because the system learned about them at the same time. - Snapshots older than the latest processed snapshot are ignored.
- Every snapshot that is newer than the latest processed snapshot is compared with the snapshot just before it. If a run is skipped, or several snapshots arrive together, the next run compares every pair in order. The first snapshot has no earlier snapshot, so all of its rows become
-
In your pipeline, apply the change table with bitemporal
AUTO CDC:- Python
- SQL
Pythonfrom pyspark import pipelines as dpfrom pyspark.sql.functions import expr@dp.viewdef employees_snapshot_changes():return spark.readStream.table("main.cdc_tutorial.employees_snapshot_changes")dp.create_streaming_table("employees_bitemporal_from_snapshot_diffs")dp.create_auto_cdc_flow(target = "employees_bitemporal_from_snapshot_diffs",source = "employees_snapshot_changes",keys = ["employeeId"],sequence_by = "snapshotDate",system_sequence_by = "recordedAt",apply_as_deletes = expr("operation = 'DELETE'"),except_column_list = ["operation"],stored_as_scd_type = "bitemporal")SQLCREATE OR REFRESH STREAMING TABLE employees_bitemporal_from_snapshot_diffs;CREATE FLOW apply_snapshot_diffs AS AUTO CDC INTOemployees_bitemporal_from_snapshot_diffsFROMstream(main.cdc_tutorial.employees_snapshot_changes)KEYS(employeeId)APPLY AS DELETE WHENoperation = "DELETE"SEQUENCE BYsnapshotDateSYSTEM SEQUENCE BYrecordedAtCOLUMNS * EXCEPT(operation)STORED ASBITEMPORAL;
- To use a column with the date that each change took effect as the business time, add it to the change table as a separate column and use it for
SEQUENCE BY. KeepsnapshotDate, because the statement uses it to record progress. - The statement can't detect a snapshot with zero rows, and a snapshot that is still loading when the job runs produces incorrect deletes. To handle both, have the loader write each snapshot's date to a table of completed snapshots after the load finishes. Then build
snapshot_pairsfrom that table instead of fromDISTINCT snapshotDate.
Additional resources
- Change data capture and snapshots: Learn about CDC concepts and how bitemporal compares to SCD Type 1 and SCD Type 2.
- The AUTO CDC APIs: Simplify change data capture with pipelines: Learn about the
AUTO CDCAPIs, including SCD Type 1 and SCD Type 2 examples and limitations. - AUTO CDC INTO (pipelines): See the SQL syntax for
SYSTEM SEQUENCE BYandSTORED AS BITEMPORAL. - create_auto_cdc_flow: See the Python syntax for
system_sequence_byandstored_as_scd_type="bitemporal". - Use change data feed on Databricks: Learn how to read a Delta table's change data feed.