SCD Type 2 history tables
Keep every version of every row for dimension tables with a database-level scd2 default, per-table overrides back to merge or append, and the queries that read current and historical state.
Most syncs keep the target identical to the source. Compliance, audit and slowly changing dimension (SCD) use cases need the opposite: when a customer's address, a driver's licence status or a location's region changes, the old row must survive next to the new one. write_strategy: scd2 does this in the Delta or Iceberg table itself, without a downstream job.
When to use it
- You need "what did this record look like on a given date" answers: pricing, customer attributes, employee assignments, reference data.
- Your dimension tables have primary keys (SCD2 requires them; keyless tables fall back to
mergeautomatically). - You accept that history tables grow with every change. Fact and event tables usually should not be SCD2.
Design used here
A HrDb database where every table is a dimension worth versioning, so scd2 is the database-wide default, with two exceptions overridden at the table level: an append-only log table and a large fact table that stays a plain merge. The output is Delta Lake on Azure; the same configuration works unchanged for Iceberg.
agent_version: 1.9.4
state_db_path: C:/dlh/state/dlh_agent_state.duckdb
connection_information:
connection_type: sql_server
server_name_or_ip: sql01.corp.local
server_port: 1433
database_names: [HrDb]
data_retrieval:
historical_load: false
output_format: delta
direct_cloud_write: true
storage_base_path: /data/delta_iceberg_tables
output_path: C:/dlh/output
parquet_compression: zstd
# Default for every table in every database below. Tables without a
# primary key fall back to merge with a warning in the run log.
write_strategy: scd2
on_schema_drift_action: migrate_target
on_schema_drift_action_handling: fail_once
databases:
- name: HrDb
sync_mode: ct
schema_refresh_mode: 1D
tables:
- name: dbo.*
all_columns: true
no_pk_strategy: full
- name: dbo.Employee
all_columns: true
masks:
- column: NationalId
algorithm: hash
param: 16
- column: Salary
algorithm: round
param: 1000
- name: dbo.TimesheetEntry
all_columns: true
write_strategy: merge
- name: dbo.AccessLog
all_columns: true
write_strategy: append
dlh_ref:
org_guid: '<YOUR_ORG_GUID>'
prj_guid: '<YOUR_PROJECT_GUID>'
connection_guid: '<YOUR_CONNECTION_GUID>'
target_schema_prefix: Site003
api_key: '<YOUR_DLH_API_KEY>'
dlh_storage:
storage_type: azure
azure_storage_uri_scheme: azure
dlh_notifications:
enabled: true
platform_run_report: trueHow the strategy is resolved per table, from most to least specific:
| Table | Resolved strategy | Why |
|---|---|---|
dbo.Employee, dbo.Department, dbo.Location, any other dbo table with a PK | scd2 | Inherited from data_retrieval.write_strategy. |
dbo.TimesheetEntry | merge | Explicit per-table override: a high-volume fact table where history is not wanted. |
dbo.AccessLog | append | Explicit per-table override: insert-only in the source. |
Any dbo table without a primary key | merge | Automatic fallback; the run log prints a warning naming the table. |
If you remove write_strategy: scd2 from data_retrieval, everything reverts to merge, the behaviour of agents before SCD2 existed. Existing configurations that never mention write_strategy are unaffected by this feature.
Masks and history
Masks run before change detection. Salary rounded to the nearest 1,000 only produces a new version when the rounded value changes, and NationalId is compared as its hash. Choose masks with that in mind: a partial mask on a free-text column can hide changes you wanted to version.
What the tables look like
Each SCD2 table has the source columns plus three metadata columns:
| Column | Meaning |
|---|---|
_scd_valid_from | UTC timestamp of the run that wrote this version. |
_scd_valid_to | UTC timestamp of the run that superseded or closed it; NULL for the current version. |
_scd_is_current | true for exactly one version per key while the key exists in the source. |
Example for one employee across three runs:
| EmployeeId | Department | _scd_valid_from | _scd_valid_to | _scd_is_current |
|---|---|---|---|---|
| 1042 | Finance | 2026-08-01 06:00:00 | 2026-08-14 09:05:00 | false |
| 1042 | Operations | 2026-08-14 09:05:00 | 2026-09-02 17:35:00 | false |
| 1042 | Operations | 2026-09-02 17:35:00 | NULL | true |
The third version exists because another column (say ManagerId) changed on 2 September; unchanged rows never create versions. If employee 1042 is deleted in the source, the current version is closed (_scd_valid_to set, _scd_is_current = false) and no new row is written.
First run and steady state
--config-checkand--diagnoseas usual.- The first run loads every table in full. Each row becomes the initial current version with
_scd_valid_fromset to that run's timestamp and_scd_valid_to = NULL. - Subsequent CT runs deliver only changed keys. For each key the agent compares the incoming values to the current version, closes it and inserts a new version if anything differs, skips it if not, and closes it without a replacement on a delete.
- The run summary counts the changed rows per table as usual;
Delta merge completelines report the total row count, which for SCD2 tables grows with history.
Switching an existing table to scd2
A table that already exists in storage without the _scd_* columns cannot be converted in place. The run reports that table as failed with a message asking for a one-time reload. Rename or delete the table folder (and the Iceberg catalog entry if you use Iceberg), then let the next run recreate it: every row of that load becomes the first version. Plan this for tables you convert after go-live.
Queries
Current state of the dimension, the view most reports should use:
CREATE OR REPLACE VIEW hr.employee_current AS
SELECT * EXCLUDE (_scd_valid_from, _scd_valid_to, _scd_is_current)
FROM raw.site003.employee
WHERE _scd_is_current = true;State as of a date, for point-in-time reporting or restating a fact table against the dimension that was current at the time:
SELECT e.*
FROM raw.site003.employee e
WHERE e._scd_valid_from <= '2026-08-20 00:00:00'
AND (e._scd_valid_to IS NULL OR e._scd_valid_to > '2026-08-20 00:00:00');Changes in a period, for an audit report:
SELECT EmployeeId, Department, _scd_valid_from, _scd_valid_to
FROM raw.site003.employee
WHERE _scd_valid_from >= '2026-08-01' AND _scd_valid_from < '2026-09-01'
ORDER BY EmployeeId, _scd_valid_from;Keys that no longer exist in the source:
SELECT EmployeeId
FROM raw.site003.employee
GROUP BY EmployeeId
HAVING SUM(CASE WHEN _scd_is_current THEN 1 ELSE 0 END) = 0;In dbt, model the current view as above and build type-2 aware joins with the _scd_valid_from and _scd_valid_to range; you no longer need a dbt snapshot for these sources.
Variations
- Only a few tables need history: drop
write_strategyfromdata_retrievaland setwrite_strategy: scd2on those table entries only. This is the recommended shape when the database is mostly facts. - Iceberg: set
output_format: icebergand the Iceberg options from CDC to Apache Iceberg on Amazon S3. The_scd_*semantics are identical. - Full sync mode: a table with
sync_mode: fullandscd2is treated as a snapshot each run: keys missing from the snapshot are closed, so do not usefullon tables where the extract might be partial. - Retention: SCD2 history is only as durable as the Delta or Iceberg table. Keep
delta_vacuum_retention_hoursat its default and take periodic storage-level backups if the history is a compliance record.
See Write strategies for the full reference, including delete+insert and incremental_key.