Crosshire
Data model
Foundation02 Data model · 4 of 5

History

Which SCD type applies to which column — a per-column decision, not a per-table one — and then SCD2 in full: four columns and a MERGE, plus about six ways to be subtly wrong.

Stand 29.07.2026

#SCD types 0 to 6, and the two this warehouse uses

What
A slowly changing dimension is the answer to one question: when an attribute changes, what happens to the rows that already referenced the old value? The numbered types are the catalogue of answers, and the first thing to fix is that the type is chosen per column, not per table. "We use SCD2" is not a design; it is half of one.
The types, what each does, and what it costs
TypeOn changeHistoryCost
0reject the change — the value is fixed for lifen/anone; wrong if the value legitimately moves
1overwrite in placenonecheapest; history is gone and cannot be recovered
2close the old row, insert a new versionfullrow count grows; every join must pin a version
3keep a previous_value column alongsideone step backcheap; a second change loses the first
4current row here, versions in a history tablefulltwo tables to keep consistent
6type 1 + 2 + 3 together — current value on every versionfull, plus as-is reportingwidest dimension; most logic to get right
This warehouse uses type 2 and type 1 in the same dimension, and business rule 4 is exactly that decision written down: a change of customer_segment creates a new SCD2 version; an address change does not.
Why
The expensive failure is not picking the wrong type. It is picking one type for a whole table. Declare the dimension "SCD2" without a column list and every attribute becomes type 2 by default — so a corrected typo in a street name, or a nightly-refreshed phone number, creates a new customer version. Row counts multiply, every fact join that fails to pin the version fans out, and revenue grows month on month for reasons no one can trace to a source change.
The mirror image is worse because it is silent. Declare it type 1 for simplicity and the first "revenue by segment, as it was at the time" request is unanswerable — not difficult, unanswerable — and the answer people accept instead is today's segment applied to last year's revenue, which is a different number that looks like the right one.
How
sql
One dimension, two types — the rule 4 split, made concrete
-- gold.dim_customer, after a customer moves house AND is re-graded.

-- TYPE 2 columns: customer_segment. A change closes the row and opens a new one.
-- TYPE 1 columns: email, phone, street, postal_code, city. Overwritten in place.

customer_sk  customer_id  city      segment  _tech_valid_from  _tech_valid_to  _tech_is_current
-----------  -----------  --------  -------  ----------------  --------------  ----------------
a1f3...      C-4711       Hamburg   B        2024-01-01        2026-03-01      false
a1f3...      C-4711       Hamburg   A        2026-03-01        NULL            true

-- Read what the address did: the customer moved from Bremen to Hamburg,
-- and BOTH rows now say Hamburg - including the row covering 2024, when
-- they lived in Bremen. That is type 1 behaving correctly, not a bug.
-- Historical address reporting was traded away deliberately, in rule 4.
That trade is the point of the whole topic. Type 1 on the address columns is not laziness — it is a decision that nobody needs to report on where a customer used to live, taken once, in writing, by someone entitled to take it. The failure is not choosing type 1; it is choosing it by default and discovering the requirement afterwards, when the history no longer exists to recover.
Types 3, 4 and 6 come up in conversation more than in delivery, and each is worth being able to place. Type 3 — a previous_segment column — is a targeted answer to "compare against the prior value" and quietly fails on the second change, so it suits attributes that change once, like a migration from an old coding scheme. Type 4 splits the current row from its versions so that the hot path stays narrow; on this platform that optimisation is rarely worth two tables. Type 6 carries the current value on every historical row, so one query can report either "as it was" or "as it is now" — genuinely useful for sales-territory reporting, and the most logic to get wrong.
Doing it
  • Write the type against every column of the dimension, in the concept, before buildingA two-column table — attribute and type — is the entire deliverable, and it is the artefact that makes rule 4 a decision rather than an accident.
  • Make the business owner sign the type 1 columns specificallyThey are agreeing that this history will not exist. That sentence, said out loud, changes about a third of the answers.
  • Default to type 2 whenever the requirement is genuinely unclearIt is the reversible direction. Stopping is easy; starting retroactively is impossible.
  • Drive the type 2 change detection from a hash of the type 2 columns only_tech_hash over the tracked columns is what stops an address correction from opening a version. Hash the whole row and you have silently made every column type 2.
  • Test that a type 1 change does NOT create a versionThe positive test — segment change creates a version — is the one everyone writes. The negative one is the one that catches a hash accidentally widened to the whole row.
Embedding it
  • Run the column-by-column pass as a workshop with the business, not as a modelling taskOnly they can say whether historical addresses matter, and doing it in a room takes an hour against weeks of asynchronous guessing.
  • Have the team state the type when proposing any new dimension columnIt becomes automatic in about two sprints and it prevents the whole-table default from ever taking hold.
  • Use the rule 4 split as the teaching exampleOne dimension containing both types makes the per-column point better than any slide, because the contradiction is visible in six rows of output.
Defaults
  • Type per column, recorded in the developer concept, signed by the business
  • Type 2 for anything you report over time; type 1 only where history is explicitly waived
  • Change detection hashes the type 2 columns only — never the whole row
  • Type 2 as the default under uncertainty, because type 1 cannot be undone
  • Both tests present: type 2 change creates a version, type 1 change does not
Gotchas
  • Declaring the table SCD2 and never naming the columnsEvery attribute becomes type 2 by default, so a corrected typo opens a new version. The row count climbs, unpinned joins fan out, and revenue rises for reasons that trace back to no source change at all.
  • Hashing the whole row for change detectionIt silently converts every type 1 column to type 2 — including nightly-refreshed technical columns, which then open a version per load. The dimension grows linearly with the number of runs and nothing in the code says so.
  • Choosing type 1 to keep the table smallThe size saving is negligible on this platform and the loss is permanent. The requirement arrives later, the data is gone from gold, and whether it can be rebuilt at all depends on whether bronze kept the extracts.
  • Type 3 on an attribute that changes more than onceprevious_segment holds exactly one step of history. The second change overwrites the first with no error and no warning, and the column keeps looking authoritative.
  • Reporting current segment against historical revenue and calling it historyJoining to _tech_is_current = true restates the past under today's classification. It is the most common wrong answer to an as-at question, it runs fast, and it produces a smooth trend line that is entirely an artefact of re-grading.

#SCD2: half-open intervals and the injected clock

What
One convention, everywhere: [_tech_valid_from, _tech_valid_to). The end is exclusive, and NULL means open.
Why
Interval boundaries cannot be recovered later. Delta's history records when rows were written; if the boundaries were stamped with the job's clock, the true change dates are gone and no amount of time travel brings them back. And the tracked-attribute list is where the business actually lives — a team that hashes every column creates a version whenever someone corrects a typo in a street name, so the dimension grows tenfold, every as-of join slows, and not one question is answered better.
How
text
One customer, one segment change, two rows
customer_sk  segment  _tech_valid_from     _tech_valid_to       _tech_is_current
a1b2...      A        2024-01-15 00:00:00  2026-03-01 00:00:00  false
a1b2...      B        2026-03-01 00:00:00  NULL                 true
The as-of predicate that follows is identical in every query, has no boundary case, and cannot count a row twice:
sql
The only correct way to join a fact to an SCD2 dimension
SELECT f.order_id, f.amount_eur, d.customer_segment
FROM   gold.fct_order_line f
JOIN   gold.dim_customer   d
  ON   d.customer_sk = f.customer_sk
 AND   f.order_ts >= d._tech_valid_from
 AND  (f.order_ts <  d._tech_valid_to OR d._tech_valid_to IS NULL)
WHERE  f.status <> 'CANCELLED';
An inclusive end forces you to subtract one unit of something, and which unit — a second, a microsecond, a day — is arbitrary, so two engineers answer it differently in two tables. The same argument rules out a 9999-12-31 sentinel: the moment one table uses it and another uses NULL, every as-of helper is wrong on half of them.
Second rule: the transform never calls current_timestamp(). The run's timestamp is a parameter.
python
src/dwh/common/scd2.py — as_of is an argument, never a call
from datetime import datetime

from pyspark.sql import DataFrame


def apply_scd2(current: DataFrame, incoming: DataFrame,
               keys: list[str], as_of: datetime) -> DataFrame:
    """Close changed versions at as_of; open their successors at as_of.

    as_of is injected so the same input yields the same output on every
    run. That property is what the idempotency test asserts.
    """
Three things break when the clock is read inside the transform:
The boundary cannot be asserted in a test
There is no expected value to compare against when the expected value is whatever time the test happened to run.
A re-run stamps today onto yesterday's change
Re-running yesterday's failed load records when the job ran, not when the business changed — so the history answers a question nobody asked.
It is fixed within one query, not across a load
current_timestamp() holds still inside a single query but not across the several a load issues. A run straddling midnight closes intervals on the 30th and opens their successors on the 31st — a one-day gap that appears only on slow runs, which is to say only in production.
Everything from here on is the hand-written MERGE path: stg_customer is the staged snapshot the load builds, and _tech_valid_from / _tech_valid_to / _tech_is_current / _tech_hash are our column names, written by this MERGE. The declarative AUTO CDC path in the ingestion route is the alternative, not the upstream — and it names its columns differently. The callout after the hash recipe reconciles the two.
sql
gold.dim_customer — one MERGE that closes and opens, with :as_of bound by the caller
MERGE INTO gold.dim_customer AS tgt
USING (
  -- every staged row once with its key (can close an open version) ...
  SELECT s.customer_sk AS merge_key, s.* FROM stg_customer s
  UNION ALL
  -- ... and the CHANGED ones again with a NULL key, so the second copy
  -- can only ever take the NOT MATCHED branch
  SELECT NULL AS merge_key, s.*
  FROM   stg_customer s
  JOIN   gold.dim_customer d
    ON   d.customer_sk = s.customer_sk AND d._tech_is_current
  WHERE  d._tech_hash <> s._tech_hash
) AS src
ON tgt.customer_sk = src.merge_key AND tgt._tech_is_current

WHEN MATCHED AND tgt._tech_hash <> src._tech_hash THEN UPDATE SET
  tgt._tech_valid_to   = :as_of,
  tgt._tech_is_current = false

-- rule 4: an address change restates the CURRENT row, it does not version
WHEN MATCHED AND (tgt.street      IS DISTINCT FROM src.street
               OR tgt.postal_code IS DISTINCT FROM src.postal_code
               OR tgt.city        IS DISTINCT FROM src.city) THEN UPDATE SET
  tgt.street          = src.street,
  tgt.postal_code     = src.postal_code,
  tgt.city            = src.city,
  tgt._tech_loaded_at = :as_of

WHEN NOT MATCHED THEN INSERT (
  customer_sk, customer_id, customer_name, email, phone, street, postal_code,
  city, country, customer_segment, region_sk,
  _tech_valid_from, _tech_valid_to, _tech_is_current, _tech_hash, _tech_loaded_at
) VALUES (
  src.customer_sk, src.customer_id, src.customer_name, src.email, src.phone,
  src.street, src.postal_code, src.city, src.country, src.customer_segment,
  src.region_sk, :as_of, NULL, true, src._tech_hash, :as_of
);
Which columns go into _tech_hash is the versioning policy. Rule 4 says a segment change makes a version and an address change does not:
sql
_tech_hash — rule 4, expressed as a column
sha2(concat_ws('||',
       coalesce(upper(trim(customer_name)),    '~'),
       coalesce(upper(trim(customer_segment)), '~'),
       coalesce(upper(trim(country)),          '~')
     ), 256) AS _tech_hash
-- street / postal_code / city are deliberately absent.
You do not have to choose the hand-written path to use this page. Choose the declarative one and put the vocabulary back with a view, once, so that nothing downstream knows which implementation produced the history:
sql
ONLY if you took the declarative route — map AUTO CDC's columns onto the contract
-- silver.customer is the AUTO CDC target from the ingestion route.
-- Gold reads this view, never the target directly, and every as-of join,
-- assertion and monitor on this page then works unchanged.
CREATE OR REPLACE VIEW silver.customer_scd2 AS
SELECT
  customer_id,
  customer_name,
  street,
  postal_code,
  city,
  country,
  customer_segment,
  -- __END_AT carries the SUCCESSOR's __START_AT, so the interval is
  -- already half-open with an exclusive end — the one thing that does
  -- line up. NULL still means open.
  CAST(__START_AT AS TIMESTAMP) AS _tech_valid_from,
  CAST(__END_AT   AS TIMESTAMP) AS _tech_valid_to,
  __END_AT IS NULL              AS _tech_is_current,
  -- AUTO CDC decides versioning from track_history_column_list, so it
  -- never materialises a hash. Recompute it here from the SAME rule-4
  -- attribute list, or the two paths disagree about what a change is.
  sha2(concat_ws('||',
         coalesce(upper(trim(customer_name)),    '~'),
         coalesce(upper(trim(customer_segment)), '~'),
         coalesce(upper(trim(country)),          '~')
       ), 256)                  AS _tech_hash
FROM silver.customer;
Running both is the one option that is always wrong: two mechanisms writing versions into one dimension produce intervals neither of them owns, and the no-overlap assertion is the only thing that will tell you.
Doing it
  • Decide hand-written MERGE or declarative AUTO CDC once, per dimension, and write it downIf declarative, create the _tech_* view in the same deployment as the pipeline so gold never sees __START_AT.
  • Bind :as_of once per run from the job's scheduled timePass that value to the pipeline, the MERGE and the test.
  • Keep the tracked-attribute list in one place, with the rule number in a comment beside it
  • Deduplicate stg_customer to one row per customer_id firstDelta fails a MERGE whose source matches a target row twice, and the SAP extract does deliver duplicates.
  • Write the no-overlap and exactly-one-current assertions before the first version row exists
Embedding it
  • Give them the SCD2 boundary-case table from the invariants routeHave them write a failing test per row before any implementation exists.
  • Ask what happens if today's load is re-run tomorrowIf the answer needs thinking about, current_timestamp() is in the code and they will find it faster than you.
  • Let them pick the tracked attributes with the business owner in the roomIt is a business decision in a technical costume.
Defaults
  • Half-open intervals, exclusive end, NULL for open, no sentinel dates anywhere
  • as_of injected; the transform is a pure function of its inputs
  • _tech_is_current is a convenience for 'today' queries, never the predicate for a historical one
  • One apply_scd2 helper for every SCD2 dimension; per-table copies drift within a quarter
Gotchas
  • Mixing the two SCD2 vocabulariesAUTO CDC writes __START_AT / __END_AT; this page's queries read _tech_valid_from / _tech_valid_to. Copying the as-of join onto a declarative target fails with UNRESOLVED_COLUMN — the cheap version. The expensive version is someone 'fixing' it by adding _tech_valid_from as a second pair of interval columns, after which two boundaries per row disagree and the as-of answer depends on which one the query picked.
  • current_timestamp() inside the transformCheap symptom: a test that can only assert 'not null'. Expensive one: a run straddling midnight leaves a one-day gap between a closed interval and its successor, on slow runs only.
  • Inclusive end datesSomeone writes BETWEEN, the boundary instant belongs to both versions, and that customer's revenue counts twice for one day per change. It reconciles to within a rounding error, so it survives review.
  • Joining on _tech_is_current for a historical questionLast year's revenue is grouped by this year's segment, every trend chart is quietly restated, and it looks exactly like the business changing.
  • A MERGE source with duplicate keysDelta raises the multiple-source-match error and the job fails — the good outcome. The bad one is a team silencing it with an arbitrary dedupe that keeps the older row half the time.
  • A NULL on either side of tgt._tech_hash <> src._tech_hashThe comparison is NULL, NULL is not true, so the MATCHED branch never fires: that customer keeps one version for the rest of the warehouse's life and no test that counts rows will see it. Declare _tech_hash NOT NULL, or compare with IS DISTINCT FROM as the address clause above does.
Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register. This is section 4 of 5 in 02 Data model.