Skip to content
About
Main content

SCD Type 2: as-was against as-is, and a late arrival

Simulate a modelNeeds Python

Problem

Customers move, products change category, and a fact recorded in March should usually be reported against what was true in March. Slowly changing type 2 keeps every version: a new dimension row (with its own and a validity range) each time an attribute changes, and each fact points to the version current when it happened. That supports two different questions from the same tables: (sales by the region the customer was in at the time) and (sales by the region they are in now). It also creates two costs: a late fact has to find the right version by date, and a correction to when a change happened forces every affected fact to be re-keyed.

Build the dim_customer table with Lena's two versions and a with the March and September sales. Run the as-was query and the as-is query and check that you get (West 100, North 200) and (North 300). Then insert a late fact dated May that arrives today, and write the range query that finds the right surrogate key. Finally, apply a late-arriving correction "Lena moved in April, not July": count how many fact rows you must re-key.

The script adds 10,000 daily facts for Lena across the year before the correction, so the count has something to measure.

The question. Do as-was and as-is give different answers from the same tables, which version does a late May fact belong to, and how many facts does the correction re-key?


Predict

Correcting the move date from July to April: how many of the 10,000 fact rows must be re-keyed?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    scd-type-2-as-was-against.py58 linesDownload
    View the code
    scd-type-2-as-was-against.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """SCD type 2: as-was against as-is, and the cost of a late correction.
    
    A customer dimension that keeps history (a new row per version, with a
    validity range), facts that point to the version current at the time, a late
    fact that must find the right version, and a correction of when the change
    happened.
    """
    import argparse, json, random, sqlite3
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--facts", type=int, default=10_000, help="Extra daily facts for Lena across the year, for the re-keying count.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    random.seed(args.seed)
    
    db = sqlite3.connect(":memory:")
    db.executescript("""
    CREATE TABLE dim_customer (sk INTEGER PRIMARY KEY, customer_id TEXT, name TEXT, region TEXT, valid_from TEXT, valid_to TEXT, is_current INT);
    INSERT INTO dim_customer VALUES (1, 'C42', 'Lena', 'West',  '2025-01-01', '2025-07-01', 0),
                                    (2, 'C42', 'Lena', 'North', '2025-07-01', '9999-12-31', 1);
    CREATE TABLE fact_sales (sale_date TEXT, customer_sk INT, amount INT);
    INSERT INTO fact_sales VALUES ('2025-03-10', 1, 100), ('2025-09-15', 2, 200);
    """)
    q = lambda sql, *a: db.execute(sql, a).fetchall()
    as_was = q("SELECT d.region, SUM(f.amount) FROM fact_sales f JOIN dim_customer d ON d.sk = f.customer_sk GROUP BY 1 ORDER BY 1")
    as_is = q("""SELECT cur.region, SUM(f.amount) FROM fact_sales f JOIN dim_customer d ON d.sk = f.customer_sk
                 JOIN dim_customer cur ON cur.customer_id = d.customer_id AND cur.is_current = 1 GROUP BY 1""")
    print("as-was:", as_was, "   as-is:", as_is)
    
    # A late fact dated May, arriving today: find the version valid on its date, not the current one
    late_sk = q("SELECT sk FROM dim_customer WHERE customer_id = 'C42' AND ? >= valid_from AND ? < valid_to", "2025-05-20", "2025-05-20")[0][0]
    db.execute("INSERT INTO fact_sales VALUES ('2025-05-20', ?, 50)", (late_sk,))
    print(f"late fact dated 2025-05-20 gets surrogate key {late_sk} (region {q('SELECT region FROM dim_customer WHERE sk = ?', late_sk)[0][0]})")
    
    # A year of daily facts, keyed correctly, then the correction "moved in April, not July"
    for _ in range(args.facts):
        d = f"2025-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}"
        sk = q("SELECT sk FROM dim_customer WHERE customer_id = 'C42' AND ? >= valid_from AND ? < valid_to", d, d)[0][0]
        db.execute("INSERT INTO fact_sales VALUES (?, ?, ?)", (d, sk, random.randint(10, 500)))
    rekey = q("SELECT COUNT(*) FROM fact_sales WHERE customer_sk = 1 AND sale_date >= '2025-04-01'")[0][0]
    total = q("SELECT COUNT(*) FROM fact_sales")[0][0]
    db.execute("UPDATE dim_customer SET valid_to = '2025-04-01' WHERE sk = 1")
    db.execute("UPDATE dim_customer SET valid_from = '2025-04-01' WHERE sk = 2")
    db.execute("UPDATE fact_sales SET customer_sk = 2 WHERE customer_sk = 1 AND sale_date >= '2025-04-01'")
    print(f"correction 'moved in April, not July': {rekey:,} of {total:,} fact rows re-keyed")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"queries": [{"view": "as-was", "result": ", ".join(f"{r} {a}" for r, a in as_was)},
                                              {"view": "as-is", "result": ", ".join(f"{r} {a}" for r, a in as_is)}],
                                  "summary": [{"late_sk": late_sk, "rekey": rekey, "total": total}]}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run scd-type-2-as-was-against.py
    uv run scd-type-2-as-was-against.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--facts10,000 Extra daily facts for Lena across the year, for the re-keying count.
--seed0 Random seed.

Our run

One set of tables, two questions

One set of tables, two questions
ViewResult
as-wasNorth 200, West 100
as-isNorth 300
Reference runApple M2 · 8 cores · 8 GB
Output · uv run scd-type-2-as-was-against.py
as-was: [('North', 200), ('West', 100)]    as-is: [('North', 300)]
late fact dated 2025-05-20 gets surrogate key 1 (region West)
correction 'moved in April, not July': 2,540 of 10,003 fact rows re-keyed
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

As-was gave North 200, West 100 and as-is gave North 300, as expected. The late fact dated 20 May found surrogate key 1, the West version valid on that date, not the current one. Correcting the move date from July to April re-keyed 2,540 of 10K fact rows: every fact between April and July had pointed to the wrong version.


Read it

That count is the cost of a late .

You cannot answer both as-was and as-is from one stored version; type 2 answers both by storing every version and resolving the current one at query time. The surrogate key is an indirection layer, and like every indirection layer it needs a that nobody owns: versions accumulate, and corrections fan out into rewrites of the facts that point at them. A correction of when something happened is the expensive kind, because it moves the boundary between versions.