Unity Catalog primary and foreign keys are informational — declare one, insert a duplicate, and the insert succeeds. They are still the only machine-readable statement of the model's intent in the warehouse, which makes them the right input to a generator rather than a reason to ignore them.
Which relationships can be declared at all is a modelling fact, not a preference. A Unity Catalog foreign key must reference a primary key, or — from DBR 18.2 — a UNIQUE constraint. gold.dim_customer is SCD2 at grain customer × validity interval, and customer_sk is sha2(concat_ws('||', customer_id), 256): it repeats once per version, so it is neither a primary key nor unique there. The dimension's declared PK is (customer_sk, _tech_valid_from). gold.fct_order_line carries customer_sk and no interval column, so there is nothing for a foreign key to reference and that relationship cannot be declared — which is why the model marks the column informational only.
Declaring PRIMARY KEY (customer_sk) to make the FK legal is the worse of the two options, not the pragmatic one: test_declared_pk_is_unique then goes red on every customer who has ever changed segment, permanently and by design, and a correct test that is red gets muted. Add RELY to it and the optimizer trusts a constraint the data violates, at which point queries may return incorrect results. So the declarations split by historisation. gold.dim_date and gold.dim_sales_region are not historised, their keys are unique, and date_sk and region_sk get real declared FKs that the generator turns into orphan tests. customer_sk and product_sk point at SCD2 dimensions and stay undeclared; the interval-aware assertion in the SQL testing route (tests/sql/fct_order_line_orphans.sql) is what covers them, hand-written because the interval predicate is not in the metadata to generate from.
Why
order_line_sk is sha2(concat_ws('||', order_id, line_number), 256). A CDC duplicate that slipped past the merge dedup produces two rows with the same surrogate key, and Unity Catalog accepts it without comment. The generated uniqueness test is the only thing standing between that and a doubled revenue figure.
RELY raises the stakes: it tells the optimizer to trust the constraint, so a violated RELY constraint does not merely fail to help — it lets the planner drop a deduplication it believes is redundant and return wrong results faster. Which is the second reason not to invent a PRIMARY KEY (customer_sk) for the sake of a declarable FK: the declaration would be violated by design, on every multi-version customer, from the first segment change onward.
How
sqlsrc/dwh/testing/constraints.py — PK_QUERY. One {catalog}, never a literal.
SELECT tc.table_schema,
tc.table_name,
array_join(sort_array(collect_list(kcu.column_name)), ', ') AS pk_columns
FROM {catalog}.information_schema.table_constraints tc
JOIN {catalog}.information_schema.key_column_usage kcu
ON kcu.constraint_catalog = tc.constraint_catalog
AND kcu.constraint_schema = tc.constraint_schema
AND kcu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema IN ('silver', 'gold')
GROUP BY tc.table_schema, tc.table_name
ORDER BY 1, 2
import os
from functools import lru_cache
from dwh.testing.constraints import FK_QUERY, PK_QUERY, SCD2_PARENT_QUERY
from dwh.testing.session import metadata_session # pytest fixtures do not
# exist at collection time
# The generator and the tests MUST read the same catalog: the "catalog"
# fixture resolves from this same variable. See the gotchas.
CATALOG = os.environ["DWH_CATALOG"]
@lru_cache(maxsize=1)
def _declared():
"""Read once at collection: (primary keys, foreign keys, SCD2 parents)."""
with metadata_session() as spark:
return ([tuple(r) for r in spark.sql(PK_QUERY.format(catalog=CATALOG)).collect()],
[tuple(r) for r in spark.sql(FK_QUERY.format(catalog=CATALOG)).collect()],
{f"{r.table_schema}.{r.table_name}" for r
in spark.sql(SCD2_PARENT_QUERY.format(catalog=CATALOG)).collect()})
def pytest_generate_tests(metafunc):
pks, fks, historised = _declared()
if "pk" in metafunc.fixturenames:
metafunc.parametrize("pk", pks, ids=[f"{s}.{t}" for s, t, _ in pks])
if "fk" in metafunc.fixturenames:
# A declared FK onto an SCD2 parent is a modelling error, not a test
# case: the parent's PK is (sk, _tech_valid_from) and a membership test
# on the sk alone would be green on exactly the rows that are wrong.
# Fail collection — loudly — instead of generating the weaker test.
bad = [f for f in fks if f[2] in historised]
assert not bad, (
f"FK declared onto a historised parent: {bad}. "
"Drop the declaration and assert it with the interval-aware "
"tests/sql/fct_order_line_orphans.sql instead."
)
metafunc.parametrize("fk", fks, ids=[f"{c}.{col}" for c, col, _, _ in fks])
def test_declared_pk_is_unique(spark, catalog, pk):
schema, table, cols = pk
dupes = spark.sql(f"""
SELECT {cols} FROM {catalog}.{schema}.{table}
GROUP BY {cols} HAVING count(*) > 1
""").count()
assert dupes == 0, f"{schema}.{table}: {dupes} duplicate {cols} groups"
def test_declared_fk_has_no_orphans(spark, catalog, fk):
"""MEMBERSHIP only: does the value exist in the parent at all.
Sound because the generator has already excluded historised parents —
for those, membership is not the question the report asks.
"""
child, child_col, parent, parent_col = fk
orphans = spark.sql(f"""
SELECT c.{child_col}
FROM {catalog}.{child} c
LEFT ANTI JOIN {catalog}.{parent} p ON p.{parent_col} = c.{child_col}
WHERE c.{child_col} IS NOT NULL
""").count()
assert orphans == 0, f"{child}.{child_col} -> {parent}: {orphans} orphans"
def test_every_gold_table_declares_a_grain(spark, catalog):
missing = spark.sql(f"""
SELECT t.table_name
FROM {catalog}.information_schema.tables t
LEFT ANTI JOIN {catalog}.information_schema.table_constraints tc
ON tc.table_schema = t.table_schema
AND tc.table_name = t.table_name
AND tc.constraint_type = 'PRIMARY KEY'
WHERE t.table_schema = 'gold'
-- gold.mv_sales is a metric view and cannot carry a PK
AND t.table_type IN ('MANAGED', 'EXTERNAL')
""").collect()
assert not missing, f"no declared grain: {[r.table_name for r in missing]}"
FK_QUERY lives beside PK_QUERY in the same module and is the same shape, joining referential_constraints to key_column_usage twice to yield child table, child column, parent table, parent column. SCD2_PARENT_QUERY is three lines against information_schema.columns — every silver or gold table carrying _tech_valid_from — and it is what makes historisation a property the generator can read rather than a list someone maintains. Between the three tests, a table added next month by someone who has never read this handbook is covered the moment its DDL declares a key, and a wrong declaration is rejected the moment it appears — which is a materially different property from a suite that covers the tables somebody remembered.
Doing it
Declare PKs and FKs in DDL even though they enforce nothingYou are writing the test specification, not a constraint.
Declare an SCD2 dimension's PK as (surrogate key, _tech_valid_from)That is the grain, not the column facts join on.
Declare FKs only onto the non-historised parents: dim_date and dim_sales_regionA fact column pointing at an SCD2 dimension gets the hand-written interval assertion, and the generator rejects the declaration if someone adds it anyway.
Generate at collection time so every table gets its own named, individually reported test
Run the generated suite against dwh_test after every load and against dwh_prod nightly
Assert the share of fact rows on the UNKNOWN member alongside the orphan check, with a threshold
Add RELY only to constraints the generated suite actually covers
Embedding it
Have them create a table without a PK and watch the grain test name it in CIThe test failing on their own table teaches the rule permanently.
Let someone declare the FK from fct_order_line to dim_customer and watch collection refuse itThe rejection message teaches the SCD2 grain rule faster than the grain rule does.
Ask 'what is the declared PK?' in reviewIt is the executable form of 'what is one row of this table?'
Give the generator to the team to ownIt is forty lines, and it is the highest-leverage forty lines in the suite.
Defaults
A declared PK on every silver and gold table(surrogate key, _tech_valid_from) wherever the table is historised.
Declared FKs onto non-historised parents only (dim_date, dim_sales_region)SCD2 parents get the interval-aware orphan assertion instead, and never a declaration.
Structural tests generated from information_schema, never hand-copied per tableThe interval predicates are the one exception — they are not in the metadata, so they are written once by hand and named as such.
Orphan test and UNKNOWN-share threshold shipped together, never separately
RELY only where the generated test runs on every load, and never on a constraint the grain contradicts
Gotchas
Declaring PRIMARY KEY (customer_sk) on an SCD2 dimension so that a fact can declare the FKcustomer_sk repeats once per version, so test_declared_pk_is_unique is red on every customer who ever changed segment — permanently, correctly, and by design — and a red test that cannot be fixed is muted within a fortnight. With RELY on it, the planner is entitled to return wrong results.
A generated orphan test against an SCD2 parentIt joins on the key alone, so it stays green while the report that joins on the interval drops every late-arriving row: the customer exists, just not yet at order_ts. The assertion and the dashboard then disagree, and the assertion is the one that is wrong.
Reading the DDL and assuming enforcementThe declaration looks exactly like a database constraint, so teams skip the test — which is the precise combination that lets a duplicate reach a dashboard.
Parametrising from a fixturepytest fixtures do not exist at collection time, so the metadata query must run in pytest_generate_tests; otherwise the suite collapses into one opaque test that reports the first failure and hides every other.
Zero orphans from a broken joinJoin on the wrong key and every fact row resolves to the UNKNOWN member, which is a valid dimension row — the orphan test is green and the segment chart is one enormous 'unknown' bar.
Generating against production metadata and running against test dataThe shape this takes in practice is dwh_prod hardcoded in the metadata query while the tests use the catalog fixture. Tables that exist only in prod then produce 'table not found' failures, and a suite that is noisy on day one is a suite that is ignored by day thirty.
Running the grain test over information_schema.tables unfilteredgold.mv_sales is a metric view and views cannot declare a primary key, so the test goes red on an object that is entirely correct — and a test that is red for a good reason gets muted within a fortnight, taking the coverage of every real gold table with it.
Every other test in this route asks whether a table is internally consistent. Reconciliation asks the only question the business asks: does gold still say what silver says? One query per rule, one stated tolerance, run after every load and before the dashboard refreshes.
Why
A perfectly unique, orphan-free, idempotent gold.fct_order_line that dropped four percent of its lines in a join passes every structural test in this route. The concrete mechanism is right there in the query: inner-join silver.fx_rate and a missing currency-day removes those lines from gold entirely. No error, no orphan, no duplicate — a smaller number. Reconciliation is the only check that sees it.
Which is why every unresolved lookup is counted rather than compared. A gold job that drops a CHF day and a reconciliation query whose own sum() skips the same NULL rate agree perfectly on a wrong number — delta is 0,00 and the check is green. The same applies one join up: an inner join to silver.sales_order for the order timestamp deletes every line whose header has not landed yet from the expected value, and gold has already dropped those same lines. lines_without_rate and lines_without_header are the columns that refuse to let both sides be wrong in the same direction, and they are the reason the tolerance can stay at one cent.
It is also the one test you can put in front of the business owner. They cannot read a parametrised SCD2 suite; they can read March: silver 4.812.331,20, gold 4.812.331,20, delta 0,00.
How
sqltests/sql/reconcile_revenue_silver_gold.sql — revenue, silver to gold, per month. Zero rows means pass.
WITH silver_revenue AS (
SELECT date_trunc('MONTH', o.order_ts) AS month,
-- EUR is the base currency and is absent from the ECB file, so it is
-- 1 by definition. Any OTHER currency with no rate must NOT vanish
-- into sum()'s NULL-skipping: it is counted on the next line.
sum(round(l.quantity * l.unit_price *
CASE WHEN l.currency = 'EUR' THEN CAST(1 AS DECIMAL(18,6))
ELSE f.fx_rate END, 2)) AS amount_eur,
count_if(l.currency IS DISTINCT FROM 'EUR'
AND f.fx_rate IS NULL) AS lines_without_rate,
count_if(o.order_id IS NULL) AS lines_without_header
FROM silver.sales_order_line l
-- LEFT on BOTH lookups. An inner join to the header is the same trap as an
-- inner join to the rate: the line disappears from the expected value and
-- from gold, both sides shrink by the same amount, and delta reads 0,00.
-- A headerless line has no order_ts, so it lands in a NULL month — which is
-- exactly what an unattributable line should look like in the output.
LEFT JOIN silver.sales_order o ON o.order_id = l.order_id
LEFT JOIN silver.fx_rate f ON f.currency = l.currency
AND f.rate_date = date(o.order_ts)
WHERE l.status IS DISTINCT FROM 'CANCELLED' -- rule 1, NULL-safe
GROUP BY 1
),
gold_revenue AS (
SELECT date_trunc('MONTH', order_ts) AS month,
sum(amount_eur) AS amount_eur
FROM gold.fct_order_line
WHERE status IS DISTINCT FROM 'CANCELLED'
GROUP BY 1
)
SELECT coalesce(s.month, g.month) AS month,
coalesce(s.amount_eur, 0) AS silver_eur,
coalesce(g.amount_eur, 0) AS gold_eur,
coalesce(g.amount_eur, 0) - coalesce(s.amount_eur, 0) AS delta,
coalesce(s.lines_without_rate, 0) AS lines_without_rate,
coalesce(s.lines_without_header, 0) AS lines_without_header
FROM silver_revenue s
FULL OUTER JOIN gold_revenue g ON g.month = s.month
-- rule 2 rounds per line on both sides from the same three DECIMAL columns, so
-- an exact match is the expectation and one cent is pure arithmetic noise.
-- Anything larger is a difference, not drift.
WHERE abs(coalesce(g.amount_eur, 0) - coalesce(s.amount_eur, 0)) > 0.01
-- an unresolved lookup makes BOTH sides smaller by the same amount, so the
-- delta stays zero and the defect is invisible. These are the rows that
-- show it — one column per lookup that can fail to resolve.
OR coalesce(s.lines_without_rate, 0) > 0
OR coalesce(s.lines_without_header, 0) > 0
ORDER BY 1
This is the same file the SQL testing route publishes as tests/sql/reconcile_revenue_silver_gold.sql. It exists once in the repository and both routes read from that one copy — a reconciliation that has been forked into two versions is a reconciliation nobody can say the result of, and the fix is to delete the copy, not to reconcile the reconciliations.
Doing it
Write one reconciliation per business rule that crosses a layer boundaryRevenue, line count, distinct customers.
Reconcile counts as well as sumsA dropped line and a duplicated line net to a matching total.
LEFT-join every lookup in the expected-value query and count the misses in a named columnHeader, rate, dimension. One INNER join anywhere in it makes the check agree with the defect.
Slice by country and currencyA matching total hides a DE surplus offsetting a CH shortfall.
Run it after every load and let it block the downstream refresh, not on a nightly schedule of its own
State the tolerance and its arithmetic reason in a comment, next to the number
Embedding it
Let the team choose the three reconciliationsWhat they pick tells you what they think the warehouse is for, which is worth knowing in week two.
Have them show the output to the business owner onceIt converts the check from an engineering artefact into a shared one, and the owner starts asking for it.
When it fails, do not debug it for them — ask which layer they trust more, and whyThat answer is the actual skill.
Defaults
Reconciliation runs after every load and gates the refresh
Zero rows means pass; the output is the differing months and the delta, not a boolean
Tolerance is a stated number with a stated cause, changed only by a commit that explains itself
Sums, counts and distinct-key counts, sliced by country and currency
The expected figure starts from silver — never from the table under test
Every unresolved lookup is counted as its own columnA reconciliation that can only report a delta cannot report the failures that shrink both sides equally.
Gotchas
Tolerance creep: the threshold gets widened once per incidentIt ends up comfortably larger than the next real defect. A tolerance that has moved twice is a bug that has been renamed.
Reconciling totals onlyOne dropped DE line and one duplicated CH line produce a matching total and two obvious failures the moment you slice by country.
Inner-joining silver.fx_rate in the gold jobA missing rate for one currency-day silently deletes those lines from gold. The disguised version is worse: left-join in the reconciliation too, and sum() skips the NULL on the silver side as well, so both sides lose the same revenue and the delta is a confident 0,00. Left-join AND count the unresolved rates.
Inner-joining silver.sales_order for the order timestampIdentical trap, different join: a line whose header is late leaves the silver side of the comparison and gold at the same time, the delta is 0,00, and the reconciliation signs off a month that is short every order still waiting on its header. lines_without_header is the only column that sees it — and a headerless line lands in the NULL month, which is the shape of the failure, not an artefact of it.
Forgetting that EUR has no ECB rateIt is the base currency, so it is absent from the file by design; a bare left join gives every DE and AT line a NULL fx_rate and the reconciliation reports most of the book missing. Someone then widens the tolerance to make the noise stop, and the tolerance is now large enough to hide a real defect.
Deriving the expected figure from goldThat proves the SQL runs. The comparison must start in silver, and ideally be written by someone who did not write the gold job.
Running it nightly while the dashboard refreshes at 06:00The business finds the error first, which costs more trust than the error itself.