02Data model
This route decides what shape gold takes, how a surrogate key is produced, and what history means here. All three are baked into rows: the key recipe is written into every fact ever loaded, and the interval convention into every query that asks what was true last March. Changing either later is a re-key of the warehouse plus a cut-over of everything that stored a key.
Choosing the shape
Three modelling styles get proposed in the first workshop. Only one is what a serving layer is for, and the argument you need is not that the other two are wrong.
#Kimball, Data Vault, or one wide table
| Kimball star | Data Vault 2.0 | One wide table | |
|---|---|---|---|
| Core unit | dim_ and fct_ | hub / link / satellite | one denormalised table per subject |
| Optimised for | reading and aggregating | loading and auditing | one dashboard |
| Objects here | 6 tables + 1 metric view | roughly three times that | one per report |
| History lives | SCD2 in the dimensions | in satellites, every attribute, always | frozen into each row |
| A new source costs | mapping into the conformed dimension | a new satellite; nothing existing changes | a rebuild |
| Analyst self-service | yes | no — a Vault is not a serving layer | yes, until the second question |
this warehouse, as a star the same domain, as a Data Vault
------------------------------ -------------------------------------
gold.dim_customer (SCD2) hub_customer + sat_customer_sap
+ sat_customer_shop
gold.dim_product (SCD2) hub_product + sat_product
gold.fct_order_line hub_sales_order + lnk_order_line
+ sat_order_line
gold.agg_revenue_month still to be built, on top of all of itcustomer_name, and DE/AT spellings of one company do the rest.Doing it
- Write the choice down in week one with its rejected alternatives; unwritten, it is re-opened every sprint.
- If a Vault exists upstream, treat it as silver and build gold on top. Proposing its removal is an argument you will lose, and should.
- Model the star from the questions the business asks. Anything nobody groups by is not a dimension.
- Facts at source grain; agg_ tables derive from fct_, never from silver.
Defaults
- Kimball star in gold; silver stays source-shaped and historised.
- One decision record: the choice, the alternatives, the date, the author.
- Facts at the finest grain delivered; aggregates derived, never authored.
- If a Vault exists upstream, it is silver. Do not build a second integration layer.
Gotchas
- Running a Vault and a star as peers. Two revenue definitions, found when an extract and a dashboard differ by a fraction of a percent — a week of arbitration each time, recurring.
- Adopting Data Vault for five sources. Object count triples and the first business-visible number arrives a month later. It earns its cost at twenty sources with provenance obligations.
- Wide tables with dimension attributes copied in. Restating history means rewriting the table, so nobody does, and the trend chart applies today's segment to last year's revenue.
Keys and constraints
How a surrogate key is produced, and what a declared primary key actually does on this platform. The second one surprises experienced people.
#Hash keys, and the separator that saves you
-- gold.dim_customer
sha2(concat_ws('||', customer_id), 256) AS customer_sk
-- gold.fct_order_line
sha2(concat_ws('||', order_id, line_number), 256) AS order_line_sk
-- Shorthand: both lines omit the normalisation. Nothing in this warehouse
-- builds a key from them. make_sk() below is the executable form, and a key
-- hashed without trim/upper/coalesce does not equal the one already stored
-- in the fact — the join returns nothing and raises nothing.fct_order_line and dim_customer build in parallel, and a full rebuild reproduces identical keys — which is what makes the idempotency test possible. Identity values depend on write order, so a backfill renumbers the dimension while every stored extract keeps the old numbers.SELECT sha2(concat('4711', '2'), 256)
= sha2(concat('471', '12'), 256) AS collides;
-- true — both hash the string '47112'.
--
-- Order 4711 line 2 and order 471 line 12 now share one order_line_sk.
-- Nothing errors. The dimension join fans out and one line's revenue
-- is attributed to the other.concat_ws fixes that and introduces the second landmine: it silently skips NULLs, so concat_ws('||','A',NULL,'B') and concat_ws('||','A','B') are one string and a key with a missing middle component collides with a shorter one. Coalesce first, to a sentinel that cannot occur in the data.from pyspark.sql import Column, functions as F
SEPARATOR = "||"
NULL_SENTINEL = "~"
def make_sk(*columns: str) -> Column:
"""Deterministic surrogate key: trim, upper, coalesce, join, sha2-256."""
parts = [
F.coalesce(F.upper(F.trim(F.col(c).cast("string"))), F.lit(NULL_SENTINEL))
for c in columns
]
return F.sha2(F.concat_ws(SEPARATOR, *parts), 256)' 4711' and '4711' are one customer to a human and two to SHA-256, and leading whitespace out of a fixed-width SAP extract is not hypothetical. Check the source before applying upper, though: it silently merges keys differing only in case — right for SAP, wrong for a case-sensitive system.Doing it
- One helper, one module. An inline sha2 expression in a model file is the copy that will diverge.
- Pick a separator that cannot occur in a business key, and add an ingestion expectation rejecting keys that contain it.
- Pin the recipe with a test containing hand-computed hashes — it stops a well-meant cleanup from changing it.
- Keep the business key visible beside the _sk; you cannot reverse a hash for a support question.
Defaults
- sha2(..., 256) over the normalised business key, stored as STRING.
- trim, upper, coalesce, concat_ws, sha2 — in that order, in one function.
- Identity columns only where the value is never referenced outside the table, which in a warehouse is almost nowhere.
- Never truncate the hash.
Gotchas
- concat instead of concat_ws. Order 4711 line 2 and order 471 line 12 hash identically, the fact gets a duplicate key, and revenue moves between customers with no error anywhere.
- concat_ws skipping NULLs. A key with a missing middle component hashes like a shorter key, so two dimension members merge into one. Row counts stay plausible; the customer is wrong.
- Truncating the hash to eight hex characters. That leaves 32 bits — a coin-flip chance of collision at about 77,000 keys, and dim_customer will pass that.
- Identity columns plus a full refresh. The dimension renumbers, the fact keeps the old numbers, nothing fails, and the joins now return different customers than yesterday.
#Constraints that do not constrain
-- every column of a primary key must ALREADY be NOT NULL,
-- or the ADD CONSTRAINT is rejected before it declares anything
ALTER TABLE gold.dim_customer ALTER COLUMN customer_sk SET NOT NULL;
ALTER TABLE gold.dim_customer ALTER COLUMN _tech_valid_from SET NOT NULL;
-- dim_customer is SCD2, so the key is the VERSION, not the customer
ALTER TABLE gold.dim_customer
ADD CONSTRAINT pk_dim_customer
PRIMARY KEY (customer_sk, _tech_valid_from);
-- now insert the same version twice
INSERT INTO gold.dim_customer
(customer_sk, customer_id, customer_name, customer_segment, country,
_tech_valid_from, _tech_valid_to, _tech_is_current, _tech_hash)
VALUES
('a1b2', 'C-4711', 'Muster GmbH', 'A', 'DE',
TIMESTAMP '2026-07-01 00:00:00', NULL, true, 'h1'),
('a1b2', 'C-4711', 'Muster GmbH', 'B', 'DE',
TIMESTAMP '2026-07-01 00:00:00', NULL, true, 'h2');
-- 2 rows inserted. No error. The primary key is still declared.RELY is where it gets interesting: it tells the optimizer to trust the declaration, permitting rewrites such as eliminating a join to a dimension whose columns are not selected. The platform still verifies nothing, so the duplicate above produces a plan built on a false premise. The violation returns wrong results faster.ALTER TABLE gold.dim_customer
ADD CONSTRAINT country_domain
CHECK (country IN ('DE', 'AT', 'CH', 'NL'));
ALTER TABLE gold.fct_order_line
ADD CONSTRAINT amount_positive
CHECK (status = 'CANCELLED' OR amount_eur > 0);
ALTER TABLE gold.fct_order_line
ALTER COLUMN order_line_sk SET NOT NULL;
-- not a business rule: a NULL _tech_hash makes the SCD2 MERGE's
-- tgt._tech_hash <> src._tech_hash comparison NULL, so that customer
-- silently stops versioning. NOT NULL is the cheapest place to stop it.
ALTER TABLE gold.dim_customer
ALTER COLUMN _tech_hash SET NOT NULL;Doing it
- Declare PK and FK wherever they are true. They cost nothing and are the model's most-read documentation.
- Add RELY only where a test proves the constraint on every run, and name that test in the decision record.
- Put the rules that must stop a load into CHECK constraints; the rest go in the SQL assertion suite.
- Get the SCD2 primary key right: (customer_sk, _tech_valid_from), never customer_sk alone.
Defaults
- PK and FK for documentation; uniqueness and orphans enforced by tests that run every build.
- RELY only behind a passing test.
- NOT NULL and CHECK for the invariants that should stop a load.
- The primary key of an SCD2 dimension includes the interval start.
Gotchas
- PRIMARY KEY (customer_sk) on an SCD2 dimension. Wrong from the second version row onward, nothing complains, and it survives to become the ER diagram everyone trusts.
- Declaring the primary key before the columns are NOT NULL. Databricks rejects the ADD CONSTRAINT outright, so you notice this one immediately — but it lands in the deployment script, not the notebook, which means it fails in the release pipeline at the point where the table already exists and the rollback story is unwritten. Set NOT NULL in the CREATE TABLE.
- A CHECK constraint whose expression evaluates to NULL. NULL is not false, so the row is accepted: CHECK (amount_eur > 0) passes every row where amount_eur is NULL, which is exactly the population you added the constraint for. Pair every CHECK on a nullable column with NOT NULL, or write the check as amount_eur IS NOT NULL AND amount_eur > 0.
- RELY on an untested constraint. The optimizer drops a join, the query gets measurably faster, and the wrong number arrives sooner. An unexplained speed-up after adding RELY is a symptom, not a win.
- Joining fct_order_line to dim_customer on customer_sk alone. A customer with three versions triples that customer's fact rows; revenue is overstated by an amount small enough to survive review.
- Believing a declared FK rejects orphans. Nothing rejects anything — the orphan assertion in the SQL testing route is the enforcement; unwritten, orphans are simply absent from every inner-joined report.
History
SCD2 is four columns and a MERGE, plus about six ways to be subtly wrong. The interval convention removes three of them; the injected clock removes another.
#SCD2: half-open intervals and the injected clock
[_tech_valid_from, _tech_valid_to). The end is exclusive, and NULL means open.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 trueSELECT 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';current_timestamp(). The run's timestamp is a parameter.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.
"""current_timestamp() is fixed within one query but not across the several a load issues — so a run straddling midnight closes intervals on the 30th and opens their successors on the 31st, a one-day gap appearing only on slow runs, which is to say only in production.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
);_tech_hash is the versioning policy. Rule 4 says a segment change makes a version and an address change does not: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.Doing it
- Bind :as_of once per run from the job's scheduled time, and pass 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 first — Delta 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.
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
- current_timestamp() inside the transform. Cheap 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 dates. Someone 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 question. Last 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 keys. Delta 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_hash. The 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.
#Deletions, late arrivals and the UNKNOWN member
bronze.sap_kna1 is a daily full extract with no change timestamp, so deletion detection is 'present yesterday, absent today' — not the same statement as 'the customer was deleted'. The policy: close the open interval at :as_of and open nothing. History stays intact, the customer has no current version, and no deleted-flag column exists to forget.UPDATE gold.dim_customer d
SET _tech_valid_to = :as_of,
_tech_is_current = false
WHERE d._tech_is_current
-- the seeded fallback member is in no extract, ever. Without this line it
-- is the first row every deletion pass closes.
AND d.customer_sk <> 'UNKNOWN'
-- NOT EXISTS, not NOT IN: one NULL customer_sk in the staging table makes
-- NOT IN evaluate to NULL for every row, and the pass closes nothing.
AND NOT EXISTS (SELECT 1
FROM stg_customer s
WHERE s.customer_sk = d.customer_sk);SELECT raise_error(
'sap_kna1 shrank by more than 10 % — refusing to apply deletions')
FROM (SELECT count(*) AS staged FROM stg_customer) s,
(SELECT count(*) AS live_rows FROM gold.dim_customer
WHERE _tech_is_current AND customer_sk <> 'UNKNOWN') t
WHERE s.staged < t.live_rows * 0.9;
-- the alias is live_rows, not current: CURRENT_DATE / CURRENT_USER and
-- friends make a bare 'current' a word you do not want to be arguing with.customer_sk — that is the trap. A computed key with no matching dimension row is an orphan, an inner join drops it, and the revenue appears nowhere. Rule 7 chooses the visible failure instead:SELECT l.order_id,
l.line_number,
coalesce(d.customer_sk, 'UNKNOWN') AS customer_sk,
o.order_ts
FROM silver.sales_order_line l
JOIN silver.sales_order o ON o.order_id = l.order_id
LEFT JOIN gold.dim_customer d
-- same recipe as make_sk(), or the join silently matches nothing
ON d.customer_sk = sha2(
concat_ws('||', coalesce(upper(trim(o.customer_id)), '~')), 256)
AND o.order_ts >= d._tech_valid_from
AND (o.order_ts < d._tech_valid_to OR d._tech_valid_to IS NULL);customer_sk = 'UNKNOWN', _tech_valid_from far in the past, no end. Seeded by the load instead, it appears only after the first unresolvable fact, so the orphan test passes in dev and fails in a clean environment. It also turns an invisible defect into a number you can alert on:SELECT date_sk,
count(*) AS unresolved_lines,
sum(amount_eur) AS revenue_at_risk
FROM gold.fct_order_line
WHERE customer_sk = 'UNKNOWN'
AND date_sk >= current_date() - INTERVAL 7 DAYS
GROUP BY date_sk
ORDER BY date_sk;Doing it
- Seed the UNKNOWN member in the same deployment script that creates the dimension.
- Exclude the UNKNOWN member explicitly from anything that compares the dimension to a source extract — the deletion pass, the shrink guard, the row-count reconciliation. It is in none of them, so every one of those comparisons is wrong by one row in the direction that matters.
- Put the shrink guard in front of every full-extract deletion pass, threshold in configuration.
- Alert when the UNKNOWN count crosses zero, not a threshold — the day it starts rising is the cheapest day to fix the source.
- Add the late-arrival and deletion cases to the fixture factory, or they will never be exercised.
Defaults
- Detected deletions close the interval. No rows deleted, no flag column added.
- A shrink guard in front of every deletion pass driven by a full extract.
- One seeded UNKNOWN member per dimension, deployed as code, referenced by rule 7.
- Facts resolve as of order_ts, never as of load time.
Gotchas
- A truncated full extract applied as deletions. Every job goes green, most of the dimension is closed, and the alarm comes from a human two days and two loads later.
- A deletion pass that does not exclude the UNKNOWN member. It is in no extract by definition, so the very first run closes it: rule 7's fallback has no current version, the orphan lines resolve to a row that is no longer valid at their order_ts, and the revenue you deliberately made visible goes back to being invisible. Nothing fails; the UNKNOWN monitor simply reads zero and looks like good news.
- NOT IN against a staging column that can be NULL. One NULL customer_sk and the predicate is NULL for every row, so the deletion pass closes nothing at all — permanently, silently, and in the direction that looks like the system working. NOT EXISTS has no such behaviour.
- Deleting the row instead of closing the interval. That customer's history is gone, last year's revenue joins to nothing, and the restore is a time-travel exercise under pressure.
- NULL instead of the UNKNOWN member. An inner join deletes the revenue from the report; a left join produces a NULL group that BI renders as a blank label and readers take for a rounding line.
- Creating the unknown member from the load rather than the deployment. The orphan test then fails on release day, in the clean environment, for a reason nobody can reproduce locally.
- Assuming unresolved facts fix themselves. fct_order_line carries no business key, so 'we will re-resolve later' means a permanent UNKNOWN bucket in the revenue report.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- Constraints on Databricks — which constraints are enforced (NOT NULL, CHECK) and which are informational (PK, FK) — read before trusting a declaration
- Use identity columns in Delta Lake — the alternative to hash keys, including the restart and concurrency behaviour that makes it a poor warehouse key
- Upsert into a Delta Lake table using merge — the matched-clause rules and the multiple-source-match error that the SCD2 MERGE above depends on
- concat_ws function — one sentence in it says NULLs are skipped; that sentence is a key-collision bug
- AUTO CDC in Lakeflow pipelines — declarative SCD Type 1 and Type 2 — worth reading against the hand-written MERGE before choosing one