Chapter 07 · Slowly Changing Dimensions: Types 0–7, History, and Effective Dating
Implement and Test an SCD2 Load Including Repeat Runs, Backdated Changes, Deletes, and Unknown Members
Build and test an idempotent AtlasMart customer SCD2 loader that survives repeat runs, backdated changes, inactivation events, unknown members, and historical fact re-keying.
Learning outcomes
The acceptance test for Chapter 07 is executable: start from one current customer row per durable identity, apply a normal change, a backdated correction, and an inactivation event, replay a duplicate event, resolve the seven accepted sales lines by business date, and prove both temporal integrity and unchanged financial controls.
Implement the local Type 2 schema, deterministic comparison hash, and idempotent change-event ledger.
Apply normal, backdated, and inactivation changes without overlapping intervals or duplicate current rows.
Resolve facts by event time with a governed unknown-member fallback.
Prove replaying the same event creates no additional Type 2 row.
Reconcile as-was/current views and all Chapter 03–06 sales control totals after re-keying.
Chapter 07 preserves the accepted AtlasMart fact controls from Chapters 01–06: paid sales remain 7 order lines, 4 paid orders, 9 sold units, 625 paid GMV, 380 cost-at-sale, and 245 gross profit. The September 20 inventory snapshot remains 137 units. Chapter 07 changes only the customer-history representation: prior chapters treated customer attributes as a current-state dimension; this chapter migrates that customer domain to versioned Type 2 rows so historical facts can bind to the customer version that was valid at business event time. The migration is explicit and must reconcile all previously accepted fact totals.
The mandatory lab uses Python's standard-library
sqlite3 module and synthetic local data. Record
your actual Python and SQLite versions before running it. The
lab uses business-effective dates and half-open intervals
[effective_from, effective_to). Those are
explicit course conventions, not universal database syntax or
legal-retention policy. Performance, cloud cost, CDC delivery
guarantees, and production concurrency behavior are outside
what this small fixture proves.
The delete scenario is modeled as a governed Type 2 inactivation, not physical erasure. Privacy erasure, legal retention, and audit requirements can conflict and vary by policy/jurisdiction; Chapter 11 treats that decision explicitly.
PRAGMA foreign_keys = ON;DROP TABLE IF EXISTS fact_sales_history;DROP TABLE IF EXISTS fact_sales_stage;DROP TABLE IF EXISTS applied_change_event;DROP TABLE IF EXISTS dim_customer_history;CREATE TABLE dim_customer_history ( customer_sk INTEGER PRIMARY KEY AUTOINCREMENT, durable_customer_id TEXT NOT NULL, source_customer_id TEXT NOT NULL, customer_name TEXT NOT NULL, segment TEXT NOT NULL, geography_code TEXT NOT NULL, lifecycle_status TEXT NOT NULL, original_signup_channel TEXT NOT NULL, effective_from TEXT NOT NULL, effective_to TEXT NOT NULL, is_current INTEGER NOT NULL CHECK (is_current IN (0,1)), change_hash TEXT NOT NULL, CHECK (effective_from < effective_to), UNIQUE (durable_customer_id, effective_from));CREATE UNIQUE INDEX ux_customer_one_current ON dim_customer_history(durable_customer_id) WHERE is_current = 1;CREATE TABLE applied_change_event ( event_id TEXT PRIMARY KEY, durable_customer_id TEXT NOT NULL, business_effective_from TEXT NOT NULL, applied_at TEXT NOT NULL);CREATE TABLE fact_sales_stage ( order_id TEXT NOT NULL, line_no INTEGER NOT NULL, event_date TEXT NOT NULL, durable_customer_id TEXT NOT NULL, product_id TEXT NOT NULL, quantity INTEGER NOT NULL, extended_amount NUMERIC NOT NULL, extended_cost NUMERIC NOT NULL, PRIMARY KEY(order_id,line_no));CREATE TABLE fact_sales_history ( order_id TEXT NOT NULL, line_no INTEGER NOT NULL, event_date TEXT NOT NULL, durable_customer_id TEXT NOT NULL, customer_sk INTEGER NOT NULL REFERENCES dim_customer_history(customer_sk), product_id TEXT NOT NULL, quantity INTEGER NOT NULL, extended_amount NUMERIC NOT NULL, extended_cost NUMERIC NOT NULL, PRIMARY KEY(order_id,line_no));INSERT INTO fact_sales_stage VALUES('O1001',1,'2026-09-18','D-CUST-001','P100',2,100,60),('O1001',2,'2026-09-18','D-CUST-001','P200',1,25,10),('O1002',1,'2026-09-18','D-CUST-002','P300',1,200,140),('O1003',1,'2026-09-19','D-CUST-001','P400',1,100,60),('O1003',2,'2026-09-19','D-CUST-001','P200',2,50,20),('O1005',1,'2026-09-20','D-CUST-004','P100',1,50,30),('O1005',2,'2026-09-20','D-CUST-004','P400',1,100,60);
import hashlib, json, sqlite3OPEN_END = "9999-12-31"def tracked_hash(customer_name, segment, geography_code, lifecycle_status): payload = json.dumps({ "customer_name": customer_name, "segment": segment, "geography_code": geography_code, "lifecycle_status": lifecycle_status, }, sort_keys=True, separators=(",", ":"), ensure_ascii=False) return hashlib.sha256(payload.encode("utf-8")).hexdigest()def apply_type2(conn, event_id, durable_id, effective_from, updates, applied_at): if conn.execute("SELECT 1 FROM applied_change_event WHERE event_id=?", (event_id,)).fetchone(): return "replay_ignored" row = conn.execute(""" SELECT customer_sk, source_customer_id, customer_name, segment, geography_code, lifecycle_status, original_signup_channel, effective_from, effective_to, is_current, change_hash FROM dim_customer_history WHERE durable_customer_id=? AND effective_from <= ? AND ? < effective_to ORDER BY effective_from DESC LIMIT 1 """, (durable_id, effective_from, effective_from)).fetchone() if not row: raise ValueError(f"No covering row for {durable_id} at {effective_from}") sk, source_id, name, segment, geo, status, signup, old_from, old_to, was_current, old_hash = row new_name = updates.get("customer_name", name) new_segment = updates.get("segment", segment) new_geo = updates.get("geography_code", geo) new_status = updates.get("lifecycle_status", status) new_hash = tracked_hash(new_name, new_segment, new_geo, new_status) if new_hash == old_hash: conn.execute("INSERT INTO applied_change_event VALUES (?,?,?,?)", (event_id, durable_id, effective_from, applied_at)) return "no_semantic_change" if effective_from == old_from: # Course policy: same-boundary arrival is correction of this interval. conn.execute("""UPDATE dim_customer_history SET customer_name=?, segment=?, geography_code=?, lifecycle_status=?, change_hash=? WHERE customer_sk=?""", (new_name, new_segment, new_geo, new_status, new_hash, sk)) conn.execute("INSERT INTO applied_change_event VALUES (?,?,?,?)", (event_id, durable_id, effective_from, applied_at)) return "corrected_existing_interval" conn.execute("UPDATE dim_customer_history SET effective_to=?, is_current=0 WHERE customer_sk=?", (effective_from, sk)) conn.execute("""INSERT INTO dim_customer_history (durable_customer_id,source_customer_id,customer_name,segment,geography_code, lifecycle_status,original_signup_channel,effective_from,effective_to,is_current,change_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (durable_id, source_id, new_name, new_segment, new_geo, new_status, signup, effective_from, old_to, was_current, new_hash)) conn.execute("INSERT INTO applied_change_event VALUES (?,?,?,?)", (event_id, durable_id, effective_from, applied_at)) return "new_version"def resolve_facts(conn): conn.execute("DELETE FROM fact_sales_history") for row in conn.execute("""SELECT order_id,line_no,event_date,durable_customer_id, product_id,quantity,extended_amount,extended_cost FROM fact_sales_stage ORDER BY order_id,line_no"""): order_id,line_no,event_date,durable,product,qty,amount,cost=row hit=conn.execute("""SELECT customer_sk FROM dim_customer_history WHERE durable_customer_id=? AND effective_from<=? AND ?<effective_to ORDER BY effective_from DESC LIMIT 1""", (durable,event_date,event_date)).fetchone() if hit: customer_sk=hit[0] else: customer_sk=conn.execute("""SELECT customer_sk FROM dim_customer_history WHERE durable_customer_id='D-UNKNOWN'""").fetchone()[0] conn.execute("INSERT INTO fact_sales_history VALUES (?,?,?,?,?,?,?,?,?)", (order_id,line_no,event_date,durable,customer_sk,product,qty,amount,cost))
1. Seed one starting version per customer
The seed state is equivalent to the current-state dimension used in earlier chapters: C001 SMB/North, C002 Consumer/West, C003 Enterprise/East, and C004 Consumer/South. The unknown member is a permanent technical row used only when a fact cannot resolve a governed customer identity.
Every starting row has
effective_from = 2026-01-01 and an open end of
9999-12-31. The unknown member begins at
0001-01-01 so it can satisfy the local fallback
contract.
2. Apply the three governed history events and one replay
apply_type2(conn, "EVT-C001-SEG-20260919", "D-CUST-001", "2026-09-19", {"segment":"Mid-Market"}, now)apply_type2(conn, "EVT-C002-GEO-20260910", "D-CUST-002", "2026-09-10", {"geography_code":"G-EAST"}, now)apply_type2(conn, "EVT-C004-INACTIVE-20260921", "D-CUST-004", "2026-09-21", {"lifecycle_status":"inactive"}, now)# Exact redelivery: event ledger makes this a no-op.assert apply_type2(conn, "EVT-C001-SEG-20260919", "D-CUST-001", "2026-09-19", {"segment":"Mid-Market"}, now) == "replay_ignored"
3. Unknown members preserve fact grain without inventing history
If a sales event carries a customer identity that cannot be resolved under the source-to-durable-key contract, the loader uses the technical Unknown member rather than a NULL foreign key or a fabricated customer version. The raw/source identity must remain available for later correction. When the real dimension member arrives, a governed late-arriving-dimension workflow can re-key the affected fact; Chapter 08 and Chapter 11 expand that topic.
Unknown does not mean “not applicable.” A missing customer identity is unknown; a business process that genuinely has no customer dimension should omit that dimension or use a separate not-applicable policy where the model requires it.
import sqlite3# Assumes schema, seed rows, tracked_hash(), apply_type2(), and resolve_facts() are loaded.# 1. Apply changes and prove exact-event replay is idempotent.assert apply_type2(conn,'EVT-C001-SEG-20260919','D-CUST-001','2026-09-19',{'segment':'Mid-Market'},now) == 'new_version'assert apply_type2(conn,'EVT-C002-GEO-20260910','D-CUST-002','2026-09-10',{'geography_code':'G-EAST'},now) == 'new_version'assert apply_type2(conn,'EVT-C004-INACTIVE-20260921','D-CUST-004','2026-09-21',{'lifecycle_status':'inactive'},now) == 'new_version'rows_before = conn.execute('SELECT COUNT(*) FROM dim_customer_history').fetchone()[0]assert apply_type2(conn,'EVT-C001-SEG-20260919','D-CUST-001','2026-09-19',{'segment':'Mid-Market'},now) == 'replay_ignored'assert conn.execute('SELECT COUNT(*) FROM dim_customer_history').fetchone()[0] == rows_before# 2. Exactly one current row per durable identity.bad_current = conn.execute('''SELECT durable_customer_id, SUM(is_current) FROM dim_customer_history GROUP BY durable_customer_id HAVING SUM(is_current) <> 1''').fetchall()assert bad_current == []# 3. No overlapping Type 2 intervals.overlaps = conn.execute('''SELECT a.durable_customer_id,a.customer_sk,b.customer_sk FROM dim_customer_history a JOIN dim_customer_history b ON a.durable_customer_id=b.durable_customer_id AND a.customer_sk < b.customer_sk AND a.effective_from < b.effective_to AND b.effective_from < a.effective_to''').fetchall()assert overlaps == []# 4. Resolve accepted sales facts by business event date.resolve_facts(conn)control = conn.execute('''SELECT COUNT(*),COUNT(DISTINCT order_id),SUM(quantity), SUM(extended_amount),SUM(extended_cost), SUM(extended_amount-extended_cost) FROM fact_sales_history''').fetchone()assert control == (7,4,9,625,380,245), control# 5. Historical/as-was segment attribution.as_was = dict(conn.execute('''SELECT d.segment,SUM(f.extended_amount) FROM fact_sales_history f JOIN dim_customer_history d ON d.customer_sk=f.customer_sk GROUP BY d.segment''').fetchall())assert as_was == {'Consumer':350,'Mid-Market':150,'SMB':125}, as_was# 6. Current/as-is segment attribution uses durable key + current row.as_is = dict(conn.execute('''SELECT d.segment,SUM(f.extended_amount) FROM fact_sales_history f JOIN dim_customer_history d ON d.durable_customer_id=f.durable_customer_id AND d.is_current=1 GROUP BY d.segment''').fetchall())assert as_is == {'Consumer':350,'Mid-Market':275}, as_is# 7. Backdated C002 geography applies to its 18 September sale.c002_geo = conn.execute('''SELECT d.geography_code FROM fact_sales_history f JOIN dim_customer_history d ON d.customer_sk=f.customer_sk WHERE f.order_id='O1002' AND f.line_no=1''').fetchone()[0]assert c002_geo == 'G-EAST'# 8. C004 was still active on its 20 September sale despite current inactivation on the 21st.c004_status = conn.execute('''SELECT d.lifecycle_status FROM fact_sales_history f JOIN dim_customer_history d ON d.customer_sk=f.customer_sk WHERE f.order_id='O1005' AND f.line_no=1''').fetchone()[0]assert c004_status == 'active'current_c004 = conn.execute('''SELECT lifecycle_status FROM dim_customer_history WHERE durable_customer_id='D-CUST-004' AND is_current=1''').fetchone()[0]assert current_c004 == 'inactive'# 9. Unknown-member fallback exists and is current.unknown = conn.execute("SELECT customer_sk FROM dim_customer_history WHERE durable_customer_id='D-UNKNOWN' AND is_current=1").fetchone()assert unknown is not Noneprint('control=',control)print('as_was=',as_was)print('as_is=',as_is)print('Chapter 07 acceptance: PASS')
Expected final line: Chapter 07 acceptance: PASS. The
acceptance suite proves interval integrity, event replay
idempotency, business-time as-of resolution, the backdated C002
correction, C004 historical-active/current-inactive semantics, and
unchanged sales controls.
4. Delete versus inactivate versus erase
The source “delete” in this fixture is interpreted as business inactivation effective 21 September. AtlasMart preserves the prior customer versions and creates an inactive version because historical reporting still needs the entity. A privacy-erasure request is a different policy problem: it may require anonymization or physical removal across dimensions, facts, exports, backups, and logs. Do not overload one SCD delete flag to represent every legal and operational scenario.
5. Rollback and restatement surface
A failed new batch can be rolled back by transaction if no downstream consumer has observed the new version. Once facts or materializations are re-keyed under a backdated correction, rollback must restore both dimension intervals and every dependent output. Keep change-event IDs, batch IDs, affected durable keys, effective boundaries, and before/after control totals so operators can identify the blast radius.
The C002 backdate is deliberately small, but the same mechanism on a production customer dimension can touch millions of facts. Measure impact before restatement and isolate historical reprocessing from current loads.
6. What this lab proves—and what it does not
It proves deterministic Type 2 semantics for one local, serial SQLite process: one current row, no overlaps, replay-safe event IDs, correct as-of binding, and control-total preservation. It does not prove concurrent writer serialization, distributed CDC ordering, exactly-once delivery, warehouse-specific MERGE semantics, legal erasure compliance, or production-scale performance.
Those non-guarantees are intentional. A production implementation must map the same invariants onto the chosen engine, transaction/isolation model, orchestration system, source CDC contract, and governance policy.
Knowledge check
Check your understanding
- What happens when the same change event ID is delivered twice?
- How does the loader decide which customer version a fact uses?
- Why does C004's 20 September sale remain historically active after a 21 September inactivation?
- What does the backdated C002 test prove?
- Which controls prove the SCD migration did not alter sales measures?
Review the answers
1. The applied-event ledger detects the replay and the second delivery creates no new Type 2 row.
2. It finds the version whose half-open effective interval contains the fact business event date; unresolved identities fall back to the explicit Unknown member.
3. The fact binds to the version valid on the event date; the inactive version begins only on 21 September.
4. The 18 September sale resolves to the geography version effective from 10 September even though that correction was processed later.
5. 7 lines, 4 orders, 9 units, 625 GMV, 380 cost, and 245 gross profit remain identical after historical re-keying.
Summary and next step
Chapter 07 makes customer history executable: version boundaries are explicit, facts resolve by business time, duplicate change delivery is idempotent, backdated corrections are visible, and current versus historical truth is a deliberate query choice. Chapter 08 builds on this foundation with date/time, role-playing, junk, degenerate, mini-, and inferred-dimension patterns.
Authoritative references
- Kimball Group — Dimensional Modeling Techniques — Authoritative index listing SCD Types 0 through 7 and their standard names.
- Kimball Group — Slowly Changing Dimensions — Why changing descriptive attributes require deliberate history policy.
- Kimball Group — Slowly Changing Dimensions, Part 2 — Type 2 new-row mechanics and Type 3 alternate-reality framing.
- Kimball Group — Design Tip #152 — Definitions and intent for advanced/hybrid SCD Types 4–7.
- SQLite — Partial Indexes — Used by the local lab to enforce one current row per durable customer.
- SQLite — CREATE TABLE — Constraint semantics used by the deterministic local lab.