Skip to main content

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

__START_AT

The business time at which this version of the record became valid.

__END_AT

The business time at which this version of the record stopped being valid. null if it is still valid.

__SYSTEM_START_AT

The system time at which the system started to believe this row.

__SYSTEM_END_AT

The system time at which the system stopped believing this row, because a later change replaced it. null if the system still believes it.

Column

Description

__START_AT

The business time at which this version of the record became valid.

__END_AT

The business time at which this version of the record stopped being valid. null if it is still valid.

__SYSTEM_START_AT

The system time at which the system started to believe this row.

__SYSTEM_END_AT

The system time at which the system stopped believing this row, because a later change replaced it. null if the system still believes 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 BY and a system time column for SYSTEM 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.

SQL
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
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"
)

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

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:

SQL
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

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:

SQL
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

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:

SQL
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

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:

SQL
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:

SQL
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:

SQL
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.

tip

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.

  1. Create the table, and enable change data feed. The isDeleted column marks records that are absent from the latest snapshot:

    SQL
    CREATE TABLE main.cdc_tutorial.employees_current (
    employeeId INT,
    name STRING,
    role STRING,
    snapshotDate DATE,
    isDeleted BOOLEAN
    )
    TBLPROPERTIES (delta.enableChangeDataFeed = true);
  2. Merge each new snapshot into the table. The following example reads a table named main.cdc_tutorial.employees_snapshots that accumulates snapshots. Each row has a snapshotDate column with the business date that its snapshot represents, and each employeeId can appear only one time per snapshot. The script merges each snapshot that is newer than the latest merged snapshot, one at a time, in ascending snapshotDate order:

    SQL
    BEGIN
    FOR snap AS
    SELECT DISTINCT snapshotDate
    FROM main.cdc_tutorial.employees_snapshots
    WHERE snapshotDate > (
    SELECT COALESCE(MAX(snapshotDate), DATE'0001-01-01')
    FROM main.cdc_tutorial.employees_current)
    ORDER BY snapshotDate
    DO
    MERGE INTO main.cdc_tutorial.employees_current AS t
    USING (
    SELECT * FROM main.cdc_tutorial.employees_snapshots
    WHERE snapshotDate = snap.snapshotDate
    ) AS s
    ON t.employeeId = s.employeeId
    WHEN MATCHED AND (t.isDeleted OR NOT (t.name <=> s.name AND t.role <=> s.role)) THEN
    UPDATE SET name = s.name, role = s.role, snapshotDate = s.snapshotDate, isDeleted = false
    WHEN NOT MATCHED THEN
    INSERT (employeeId, name, role, snapshotDate, isDeleted)
    VALUES (s.employeeId, s.name, s.role, s.snapshotDate, false)
    WHEN NOT MATCHED BY SOURCE AND NOT t.isDeleted THEN
    UPDATE 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 = true instead 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 snapshotDate in employees_current records 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.
  3. In your pipeline, read the change data feed and apply it with bitemporal AUTO CDC. The _commit_timestamp metadata column, which records when the merge committed, is the system time. Skip update_preimage rows, which contain the values before each update. Set the starting version to 0. Otherwise, the first pipeline run reads the table's current contents as inserts, and every earlier merge collapses into one system time.

    Python
    from pyspark import pipelines as dp
    from pyspark.sql.functions import col, expr

    @dp.view
    def 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"
    )

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.

  1. Create the change table:

    SQL
    CREATE TABLE main.cdc_tutorial.employees_snapshot_changes (
    employeeId INT,
    name STRING,
    role STRING,
    operation STRING,
    snapshotDate DATE,
    recordedAt TIMESTAMP
    );
  2. 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 a snapshotDate column. Each snapshot's date is the business time, and the time the job runs is the system time:

    SQL
    INSERT INTO main.cdc_tutorial.employees_snapshot_changes
    WITH snapshot_pairs AS (
    SELECT snapshotDate, LAG(snapshotDate) OVER (ORDER BY snapshotDate) AS prevSnapshotDate
    FROM (SELECT DISTINCT snapshotDate FROM main.cdc_tutorial.employees_snapshots)
    ),
    pending AS (
    SELECT * FROM snapshot_pairs
    WHERE 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.role
    FROM pending p
    JOIN main.cdc_tutorial.employees_snapshots s ON s.snapshotDate = p.snapshotDate
    ),
    prev AS (
    SELECT p.snapshotDate, s.employeeId, s.name, s.role
    FROM pending p
    JOIN main.cdc_tutorial.employees_snapshots s ON s.snapshotDate = p.prevSnapshotDate
    )
    SELECT
    COALESCE(curr.employeeId, prev.employeeId) AS employeeId,
    curr.name,
    curr.role,
    CASE
    WHEN 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 recordedAt
    FROM curr
    FULL OUTER JOIN prev
    ON curr.snapshotDate = prev.snapshotDate AND curr.employeeId = prev.employeeId
    WHERE prev.employeeId IS NULL
    OR curr.employeeId IS NULL
    OR 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 INSERT events.
    • The latest snapshotDate in the change table records progress, and the whole run is one INSERT, 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 recordedAt value, because the system learned about them at the same time.
    • Snapshots older than the latest processed snapshot are ignored.
  3. In your pipeline, apply the change table with bitemporal AUTO CDC:

    Python
    from pyspark import pipelines as dp
    from pyspark.sql.functions import expr

    @dp.view
    def 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"
    )
note
  • 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. Keep snapshotDate, 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_pairs from that table instead of from DISTINCT snapshotDate.

Additional resources​