Crosshire
← Handbook
BuildAssertions · harness · oracles

08Testing · SQL

PySpark tests prove the transform. They cannot prove the warehouse, because the warehouse is where eleven million rows meet a rule that held on the twelve rows in the factory. This route decides what a SQL assertion looks like, which eight this warehouse ships with, and how two result sets get compared without the comparison quietly agreeing with itself.

Stand 29.07.2026

The pattern

Two ideas carry the route: a test is a query that should return nothing, and a directory of such queries becomes a suite once twelve lines of Python have looked at it.

#Zero rows means pass

What
An assertion is a SELECT that returns the rows violating a rule. Empty result set, pass. There is no library and no DSL — the query is simultaneously the check and the error message, because the rows it returns on failure are the evidence needed to fix it.
sql
tests/sql/dim_customer_unique_key.sql
-- SCD2 dimension: the business key plus the interval start must be unique.
-- Zero rows = pass.
SELECT
  customer_id,
  _tech_valid_from,
  COUNT(*) AS versions
FROM gold.dim_customer
GROUP BY customer_id, _tech_valid_from
HAVING COUNT(*) > 1
Compare the shape most teams write first. It passes and fails just as accurately and tells you nothing:
sql
The same check, useless on failure
SELECT CASE WHEN COUNT(*) = 0 THEN 'PASS' ELSE 'FAIL' END AS result
FROM (
  SELECT customer_id FROM gold.dim_customer
  GROUP BY customer_id, _tech_valid_from HAVING COUNT(*) > 1
)
One file, one rule, one statement, named <table>_<property>.sql. The file name becomes the test name, so a red build says dim_customer_scd2_no_overlap rather than test_sql_3. Nothing inside is catalog-qualified — the harness sets the catalog, which is what lets one file run against a seeded CI schema and against production.
Why here
These catch the class of bug a PySpark unit test structurally cannot reach: the transform was correct on the twelve rows in the factory and wrong on the warehouse, because two sources both supplied customer 4711, because silver.fx_rate had no row for a bank holiday, because a job was re-run and a MERGE matched twice.
They are also the only tests that run against production data. A unit test proves the code you shipped; an assertion proves the table you have — the second is what a stakeholder means when they ask whether the numbers are right.
Doing it
  • Write the assertion in the same pull request as the table. One added later is calibrated against data that already exists, which is the opposite of a test.
  • Select the identifying columns plus the values that explain the violation — key alone is reproducible, key plus expected and actual is diagnosable.
  • Run the suite as a job task after the nightly load, not only in CI. Production data breaks assertions that seeded fixtures never will.
Embedding it
  • Hand the team a failing assertion with no explanation. If the returned rows do not explain the failure, the assertion is under-selected — a discovery lands better than a style rule.
  • Ask in review which of the seven business rules a new gold table can now violate. That answer is the assertion backlog, and they generated it.
  • Re-run last month's suite against a time-travel snapshot. An assertion going red on signed-off data teaches coverage faster than any talk.
Defaults
  • One rule per file; the file name is the test name.
  • Return the offending rows, never a verdict.
  • Never catalog-qualify inside the .sql file — the harness owns the catalog.
  • An assertion that has never been red is unproven. Break it deliberately once.
Gotchas
  • An assertion written after the table exists is fitted to the data, not to the rule. It stays green through the bug it was written for, because that bug was already in the table when the author calibrated it.
  • NOT IN with a nullable column is the quietest failure here. country NOT IN ('DE','AT','CH','NL') is NULL when country is NULL, so the row is not returned and the check passes on exactly the rows that are most broken. Every domain assertion needs an explicit IS NULL OR arm.
  • Assertions that only run in CI. CI runs against fixtures, and fixtures never contain the duplicate SAP sent at 03:00 on a Sunday.
  • Selecting only the key column. The failure is reproducible but not diagnosable, so the on-call engineer re-runs it by hand with more columns — every time, at 02:00, forever.

#Twelve lines that turn a directory into a suite

What
The whole runner. It globs the directory, parametrises pytest with the file stems as test IDs, and asserts the result is empty.
python
tests/sql/test_assertions.py — the entire harness
import pathlib
import pytest

SQL_DIR = pathlib.Path(__file__).parent
CASES = sorted(SQL_DIR.glob("*.sql"))
assert CASES, f"no .sql assertions found under {SQL_DIR}"

@pytest.mark.parametrize("case", CASES, ids=[c.stem for c in CASES])
def test_assertion(spark, catalog, case):
    spark.sql(f"USE CATALOG {catalog}")
    bad = spark.sql(case.read_text()).take(20)
    assert not bad, "{}: {} violating row(s) shown, capped at 20\n{}".format(
        case.stem, len(bad), "\n".join(str(r.asDict()) for r in bad)
    )
Three details are load-bearing. ids=[c.stem ...] makes the report read test_assertion[fct_order_line_orphans], so the failure names the property. take(20) bounds the blast radius. And assert CASES exists because pytest skips an empty parametrize list — without it, a packaging change that leaves the files out produces a green run with zero tests.
The spark and catalog fixtures come from the same conftest.py as the PySpark suite. One difference matters: this suite has no local mode. No local session contains gold.dim_customer, so it always runs remotely — against dwh_test after CI has loaded, or against dwh_prod on a schedule.
yaml
resources/assertions.job.yml — the scheduled production run
resources:
  jobs:
    dwh_assertions:
      name: "dwh · SQL assertions · ${bundle.target}"
      tasks:
        - task_key: assertions
          # NO environment_key here. Serverless notebook tasks are reported to
          # reject it — register key serverless-environment-key, status hot and
          # explicitly UNVERIFIED. Reproduce on the client workspace before you
          # either rely on it or design around it.
          notebook_task:
            # thin wrapper: runs pytest over tests/sql and writes the JUnit XML
            # to the ci Volume. All the logic is in test_assertions.py above.
            notebook_path: ../tests/sql/run_assertions.py
            base_parameters:
              catalog: ${var.catalog}
      schedule:
        quartz_cron_expression: "0 30 5 * * ?"
        timezone_id: "Europe/Zurich"
      email_notifications:
        on_failure: ["${var.oncall_rota}"]
Why here
A harness this small is a governance decision disguised as code. Adding a check costs one file and no Python, so analysts add checks. The moment a test requires learning a framework, the only people writing tests are the people who already write tests — and the business rules stay untested because whoever knows them cannot express them.
The parametrised shape also gives the failure list for free: a broken load produces four red test IDs rather than one red suite, and the names describe the breakage before anyone opens a log.
Doing it
  • Reuse the conftest.py from the PySpark suite. Two session builders drift, and the drift shows up as a test that passes in one suite and fails in the other.
  • Write the JUnit XML to the ci Volume in dwh_test so the report survives the cluster.
  • Point the scheduled job at the on-call rota, never at an individual and never at your own address.
Embedding it
  • Ask an analyst, not an engineer, to add the ninth assertion. If they need help, fix the harness rather than train the analyst.
  • Delete one .sql file in front of the team and let CI stay green. Missing tests are invisible in the abstract and obvious once watched.
  • When a production assertion goes red, sit next to whoever is on call and let them use the rows. Do not fix it.
Defaults
  • Twelve lines. If the harness grows, the growth belongs in the .sql files.
  • File stem as test ID, so failures are self-describing.
  • Always remote — there is no local mode for warehouse assertions.
  • Bounded collection: take(n), never collect().
Gotchas
  • collect() instead of take(). A missing join predicate turns an orphan check into a cross join, the driver pulls millions of rows, and the suite dies of an OOM twenty minutes in — which reads as infrastructure flakiness and gets retried rather than fixed.
  • An empty glob is a silent green. pytest skips a zero-length parametrize list, so a packaging change produces a passing run with no tests and nobody reads the collected-count line.
  • A missing table or parse error raises, so pytest reports ERROR rather than FAILURE. A CI gate that greps the summary for 'failed' reports green while every assertion errored — gate on the exit code.
  • Running assertions before the load in the same job. They pass against yesterday's data and the job is green while today's load is still failing. Depend on the load task explicitly.
  • Assuming the serverless task takes an environment_key. It is reported to be rejected and the report is not doc-confirmed, so a copied job definition can fail at deploy time with a message about a field that is documented elsewhere. Deploy the job to a scratch target once before you promise a schedule.

The eight assertions

Four are transcriptions of something already declared — the key, the grain, the foreign keys, the country list. Four are the seven business rules made executable. Between them they cover every way this warehouse has actually been wrong.

#Four the model already declared

What
The first two are one query applied to two tuples: the SCD2 key of gold.dim_customer above, and the declared grain of gold.fct_order_line.
sql
tests/sql/fct_order_line_grain.sql — grain is 'one row per order line'
SELECT
  order_id,
  line_number,
  COUNT(*)                        AS rows_at_grain,
  COUNT(DISTINCT order_line_sk)   AS distinct_sks
FROM gold.fct_order_line
GROUP BY order_id, line_number
HAVING COUNT(*) > 1
distinct_sks is not decoration. If it is 1, the load ran twice and inserted the same surrogate key twice — an idempotency bug. If it equals rows_at_grain, the hash inputs differ, so the business key is not what the model says it is. Two causes, told apart by one column in the failure output.
The orphan check is the one written wrong most often. UC foreign keys are informational, so nothing prevents a fact row pointing at a customer version that does not exist. Rule 7 says every line resolves to a current customer or to the unknown member — and since customer_sk derives from customer_id alone, resolving it means joining on the interval:
sql
tests/sql/fct_order_line_orphans.sql
SELECT
  f.order_line_sk,
  f.customer_sk,
  f.order_ts
FROM gold.fct_order_line f
LEFT JOIN gold.dim_customer d
       ON d.customer_sk = f.customer_sk
      AND f.order_ts >= d._tech_valid_from
      AND (d._tech_valid_to IS NULL OR f.order_ts < d._tech_valid_to)
-- IS DISTINCT FROM, not <>: a NULL customer_sk must be REPORTED, and
-- f.customer_sk <> 'UNKNOWN' is NULL on exactly those rows.
WHERE f.customer_sk IS DISTINCT FROM 'UNKNOWN'
  AND d.customer_sk IS NULL
Without the two interval predicates this passes on a late-arriving dimension — the customer exists, just not yet at order_ts — while the report that joins on the interval silently loses the row. The assertion has to join the way the report joins.
sql
tests/sql/dim_customer_country_domain.sql — rule 3
SELECT
  country,
  COUNT(*) AS affected_rows,
  MIN(customer_id) AS example_customer
FROM gold.dim_customer
WHERE country IS NULL
   OR country NOT IN ('DE', 'AT', 'CH', 'NL')
GROUP BY country
Why here
These four make a number wrong without making anything fail. A duplicated fact line double-counts revenue and no job goes red. An orphaned line drops out of every dashboard that joins to dim_customer, so the total comes back smaller — unnoticed, because nobody knows what it should be.
They are also the four you can generate. Key, grain and foreign keys are already declared on the table; the domain is already in the business rules. The assertion is a transcription, not an invention — which is why a team handed this is productive the same afternoon.
Doing it
  • Generate the uniqueness, grain and orphan assertions from the declared PK/FK metadata; hand-write only the interval predicates.
  • Put the UNKNOWN-member exclusion in the WHERE clause explicitly, so the reader sees that unresolved rows are a policy and not a leak.
  • Add the diagnostic column — distinct_sks, example_customer — to every grouped assertion.
  • Assert the domain against the same literal list the transform uses. If the list appears twice in the repository it will disagree with itself within a quarter.
Embedding it
  • Give them the grain assertion and ask them to break it two ways. Producing both the double-load and the wrong-hash failure teaches the diagnostic column without being told.
  • In review, ask what happens to this assertion when a dimension arrives late. Fastest way to find out whether the author has internalised the interval join.
  • Let the team write the generator over the PK/FK metadata. An afternoon, and the suite stops being something you gave them.
Defaults
  • Uniqueness, grain and orphans are generated from declarations, not typed per table.
  • Every orphan check joins on the validity interval, with an exclusive end.
  • Domain lists live in one place, referenced by both transform and assertion.
  • The UNKNOWN member is excluded explicitly, never by a NOT NULL on the key.
Gotchas
  • Trusting a declared PRIMARY KEY. UC constraints are informational — declare one, insert a duplicate, it succeeds. With RELY the optimizer believes the declaration and can eliminate a join, so a violation returns wrong results faster than before you declared it.
  • An orphan check joining on customer_sk alone. It stays green while the report that joins on the interval quietly drops every late-arriving row — so assertion and dashboard disagree, and the assertion is the one that is wrong.
  • Checking COUNT(*) on the surrogate key instead of the business key. A key that normalises differently — '4711' versus ' 4711' — hashes to two different sks, and the check is blind to the case it was written for.
  • A grain assertion without the distinct_sks column. You learn there are duplicates, then spend an hour deciding whether the load is not idempotent or the key is wrong. The answer was one column away.

#Four the business rules declared

What
SCD2 correctness is two assertions and neither alone suffices. The first says no customer has two versions covering the same instant; the second says exactly one version is open, flagged, and the same row.
sql
tests/sql/dim_customer_scd2_no_overlap.sql
-- Intervals are [_tech_valid_from, _tech_valid_to) — end is EXCLUSIVE.
-- The asymmetric join reports each offending pair once, not twice.
SELECT
  a.customer_id,
  a._tech_valid_from AS earlier_from,
  a._tech_valid_to   AS earlier_to,
  b._tech_valid_from AS later_from
FROM gold.dim_customer a
JOIN gold.dim_customer b
  ON  b.customer_id       = a.customer_id
  AND b._tech_valid_from  > a._tech_valid_from
WHERE a._tech_valid_to IS NULL
   OR b._tech_valid_from < a._tech_valid_to
sql
tests/sql/dim_customer_scd2_one_current.sql
SELECT
  customer_id,
  COUNT_IF(_tech_is_current)                            AS flagged_current,
  COUNT_IF(_tech_valid_to IS NULL)                      AS open_intervals,
  COUNT_IF(_tech_is_current AND _tech_valid_to IS NULL) AS both_on_same_row
FROM gold.dim_customer
GROUP BY customer_id
HAVING COUNT_IF(_tech_is_current)                            <> 1
    OR COUNT_IF(_tech_valid_to IS NULL)                      <> 1
    OR COUNT_IF(_tech_is_current AND _tech_valid_to IS NULL) <> 1
The third HAVING arm earns its keep. A partially applied update leaves the flag on the old row and the open interval on the new one — one of each, so the first two arms pass, while queries filtering on _tech_is_current return the superseded version and queries filtering on _tech_valid_to IS NULL return the right one. Two dashboards, two answers, no error anywhere.
Currency conversion is checked row by row against rules 2 and 5:
sql
tests/sql/fct_order_line_amount_eur.sql
SELECT
  order_line_sk,
  currency,
  fx_rate,
  quantity,
  unit_price,
  amount_eur,
  status,
  ROUND(quantity * unit_price * fx_rate, 2) AS expected_eur
FROM gold.fct_order_line
WHERE amount_eur IS DISTINCT FROM ROUND(quantity * unit_price * fx_rate, 2)
   OR (currency = 'EUR' AND fx_rate IS DISTINCT FROM 1)
   OR (status IS DISTINCT FROM 'CANCELLED' AND COALESCE(amount_eur, 0) <= 0)
   OR currency IS NULL
   OR currency NOT IN ('EUR', 'CHF')
IS DISTINCT FROM rather than <> throughout: a NULL amount_eur compared with <> yields NULL, the row is not returned, and the assertion passes on the rows where conversion failed hardest.
The fourth recomputes from silver.fx_rate(currency, rate_date, fx_rate), one row per currency per day — rather than reusing the fx_rate gold already stored. Reusing gold's own rate would make the test tautological: it would prove that multiplication works.
sql
tests/sql/reconcile_revenue_silver_gold.sql — rules 1 and 2
WITH silver_month 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. Rule 2 rounds PER LINE; rounding the sum drifts by cents.
    SUM(ROUND(l.quantity * l.unit_price *
              CASE WHEN l.currency = 'EUR' THEN CAST(1 AS DECIMAL(18,6))
                   ELSE r.fx_rate END, 2))                AS silver_eur,
    -- a missing rate makes BOTH sides smaller by the same amount, so the delta
    -- stays zero. This column is the only place that defect is visible.
    COUNT_IF(l.currency IS DISTINCT FROM 'EUR'
             AND r.fx_rate IS NULL)                       AS lines_without_rate
  FROM silver.sales_order_line l
  JOIN silver.sales_order      o ON o.order_id  = l.order_id
  LEFT JOIN silver.fx_rate     r ON r.currency  = l.currency
                                AND r.rate_date = DATE(o.order_ts)
  WHERE l.status IS DISTINCT FROM 'CANCELLED'
  GROUP BY 1
),
gold_month AS (
  SELECT
    DATE_TRUNC('MONTH', order_ts) AS month,
    SUM(amount_eur)               AS gold_eur
  FROM gold.fct_order_line
  WHERE status IS DISTINCT FROM 'CANCELLED'
  GROUP BY 1
)
SELECT
  COALESCE(s.month, g.month)                            AS month,
  s.silver_eur,
  g.gold_eur,
  COALESCE(g.gold_eur, 0) - COALESCE(s.silver_eur, 0)   AS delta_eur,
  COALESCE(s.lines_without_rate, 0)                     AS lines_without_rate
FROM silver_month s
FULL OUTER JOIN gold_month g ON g.month = s.month
WHERE ABS(COALESCE(g.gold_eur, 0) - COALESCE(s.silver_eur, 0)) > 0.01
   OR COALESCE(s.lines_without_rate, 0) > 0
Why here
These four are the ones a stakeholder can read. 'Revenue in silver and gold agree to the cent, every month, and no line was valued without a rate' is a sentence a finance lead can sign off. 'The FK constraint is satisfied' is not.
They also close the loop the layer contract opened. Silver is source-shaped, gold is modelled, and between them sits a transform someone wrote. The reconciliation makes 'gold is derived from silver' a checked claim rather than an architectural intention.
Doing it
  • Assert both SCD2 properties, including the both-on-same-row arm. The pair is the test; either alone is a false sense of coverage.
  • Use IS DISTINCT FROM in every predicate, without deciding case by case whether a column can be NULL.
  • Recompute the reconciliation from silver.fx_rate, never from the rate the fact already stored, and LEFT JOIN it so an absent rate becomes a counted column rather than a deleted row.
  • Reconcile at month grain and also for the current month to date — a monthly-only reconciliation finds yesterday's break in four weeks.
Embedding it
  • Seed a customer whose flag and open interval disagree, then ask the team why two dashboards give two answers. Nobody forgets the third HAVING arm afterwards.
  • Have whoever wrote the transform explain why the reconciliation must not reuse gold's fx_rate. If they cannot, they will write the tautological version next time.
  • Ask the finance lead to state the tolerance they can live with, in euros. It turns a parameter into an agreement.
  • Hand them a green reconciliation with an INNER JOIN and a deliberately missing month. Let them find it.
Defaults
  • Exclusive interval ends everywhere; assertion and transform share the convention or both are wrong.
  • IS DISTINCT FROM by default in assertions.
  • Reconciliations are FULL OUTER and independent of the value they check.
  • Rules 1–7 map one-to-one onto assertions. A rule with no assertion is a comment.
Gotchas
  • An inclusive interval end. If _tech_valid_to equals the next version's _tech_valid_from and the join uses <=, every boundary instant matches two versions and every temporal join double-counts — small enough to look like rounding, large enough to fail an audit.
  • The SCD2 flag and the open interval drifting apart. Both single-property assertions pass, and the warehouse serves two different 'current' customers depending on which predicate the query used.
  • Rounding at the wrong point. Rule 2 rounds per line; summing unrounded values and rounding the total drifts by cents per thousand lines — just inside a naive tolerance and just outside a finance reconciliation.
  • A percentage tolerance. It scales with the error it is meant to catch, so a conversion bug costing 0.3 % of revenue is invisible at every volume.
  • Reconciling against gold's own stored fx_rate. Green by construction, through a completely wrong rate table.
  • An INNER JOIN from silver.sales_order_line to silver.fx_rate. The ECB file has no EUR row — EUR is the base currency — so every euro line vanishes from the silver side, and a reconciliation between DE/AT/NL revenue and total revenue reports a delta of exactly the euro business. It looks like a catastrophic transform bug and is a join.
  • A missing rate for a real currency. It removes the same lines from silver and from gold, the delta stays zero, and the test is green on a month that is short by one currency for one day. Only the lines_without_rate column sees it.

Comparing two result sets

Half the assertions above are one question — do these two queries return the same thing — and the SQL for that question has three traps in it, each of which produces a green test.

#EXCEPT, symmetrically and with counts

What
A EXCEPT B returns rows in A that are not in B and says nothing about rows in B that are not in A. A one-directional comparison of a deployed table against a rebuild therefore catches missing rows and is blind to extra ones — the more common failure, because extra rows are what a re-run produces.
Second trap: EXCEPT is EXCEPT DISTINCT. It deduplicates both inputs before subtracting, so one copy of a row and three copies compare as identical. Whenever counts matter — in a warehouse, always — the operator is EXCEPT ALL.
sql
tests/sql/dim_customer_rebuild_parity.sql — symmetric, count-preserving
WITH rebuilt AS (
  SELECT
    -- make_sk() verbatim: trim, upper, coalesce to the sentinel, concat_ws,
    -- sha2. Any deviation and EVERY row reports as both missing and extra.
    sha2(concat_ws('||', COALESCE(UPPER(TRIM(customer_id)), '~')), 256)
                                        AS customer_sk,
    customer_id,
    customer_name,
    country,
    customer_segment,
    CAST(_tech_valid_from AS TIMESTAMP) AS _tech_valid_from,
    CAST(_tech_valid_to   AS TIMESTAMP) AS _tech_valid_to
  FROM silver.customer
),
deployed AS (
  SELECT
    customer_sk, customer_id, customer_name, country, customer_segment,
    _tech_valid_from, _tech_valid_to
  FROM gold.dim_customer
)
SELECT 'missing_from_gold' AS side, *
FROM (SELECT * FROM rebuilt  EXCEPT ALL SELECT * FROM deployed)
UNION ALL
SELECT 'extra_in_gold' AS side, *
FROM (SELECT * FROM deployed EXCEPT ALL SELECT * FROM rebuilt)
The side column matters more than it looks: missing and extra have different causes and different fixes.
Third trap, and the subtlest. Set operators treat NULL as equal to NULL, so EXCEPT is already NULL-safe. An equality join is not. Rewriting this as LEFT JOIN ... WHERE b.key IS NULL — which reads as the same check — changes the answer for every nullable column, because NULL = NULL is NULL and the row never matches:
sql
The same comparison as a join, done correctly
-- same WITH rebuilt AS (...), deployed AS (...) header as above
SELECT d.customer_sk, d._tech_valid_from
FROM deployed d
LEFT JOIN rebuilt r
       ON  r.customer_sk       IS NOT DISTINCT FROM d.customer_sk
       AND r._tech_valid_from  IS NOT DISTINCT FROM d._tech_valid_from
       AND r.customer_segment  IS NOT DISTINCT FROM d.customer_segment
-- customer_sk is a hash and is never NULL, which is the ONLY reason it can
-- serve as the did-it-match sentinel. Pick a nullable column here and the
-- anti-join reports matched rows as missing.
WHERE r.customer_sk IS NULL
Why here
Every refactor of a gold model ends with the same question: is the new table the same as the old one? Getting that comparison wrong is worse than not running it, because it produces a signed-off migration — the rebuild is verified, the extra rows the old job left behind are not, and they ship.
The three traps share a property that makes them expensive: each one fails toward green. A one-directional comparison, a deduplicating operator and a NULL-unsafe join all return fewer rows than the correct query, and an assertion that returns fewer rows is an assertion that passes. Nothing about a green parity check tells you which of the three you wrote.
Doing it
  • Compare in both directions, always, with a side column naming the direction.
  • EXCEPT ALL by default. Reach for EXCEPT DISTINCT only when you can say out loud why duplicates are acceptable.
  • Cast both sides to the same declared types inside the CTEs, before the set operator sees them.
  • Keep the column list explicit on both sides. SELECT * over two tables that have drifted by one column fails with a positional type error naming the wrong column.
Embedding it
  • Show it rather than say it: duplicate one row in a copy of dim_customer and run EXCEPT and EXCEPT ALL side by side. Ten seconds, and the rule never has to be repeated.
  • Add 'is the comparison symmetric?' to the review checklist for any migration pull request.
  • Have the team write the parity assertion for the next migration themselves; review only the direction and the operator.
Defaults
  • Symmetric EXCEPT ALL with a side column, as one file.
  • Explicit column lists and explicit casts in both CTEs.
  • IS NOT DISTINCT FROM in join predicates; IS DISTINCT FROM in filters.
  • Parity assertions are temporary by design — delete them when the migration is done.
Gotchas
  • One-directional EXCEPT. It catches missing rows and is blind to extra ones, so a re-run that duplicated a day's load passes the parity check written specifically to catch it.
  • Plain EXCEPT when counts matter. It deduplicates both sides first; one row versus three compares as identical, and the report double-counts revenue with a green test behind it.
  • Implicit coercion across the set operator. DECIMAL(18,2) on one side and DOUBLE on the other coerce silently to DOUBLE, cent-level differences appear that exist nowhere in the data, and you spend a day looking for a rounding bug in a correct transform.
  • Column order. Set operators match by position, not by name, so two SELECTs with the same columns in a different order compare successfully and compare the wrong things whenever the types line up.
  • Retyping the key recipe in the rebuild CTE. TRIM(UPPER(x)) where make_sk does COALESCE(UPPER(TRIM(x)), '~') hashes every row to a different value, so the parity check reports 100 % missing and 100 % extra. That reads as a catastrophic migration and is a paraphrased expression — paste the recipe with a comment naming its source, or call the function.

#Testing a metric view against an oracle

What
Unity Catalog metric views are GA and their measures are read with the MEASURE() function. gold.mv_sales is where revenue is defined once for AI/BI dashboards, Genie, notebooks and Power BI — so a mistake in it is a mistake in all of them at once.
The test is an oracle: an independent expression of the same number, in plain SQL against gold.fct_order_line, compared symmetrically with the view.
sql
tests/sql/mv_sales_oracle.sql
WITH from_view AS (
  SELECT
    country,
    CAST(month AS DATE)                      AS month,
    CAST(MEASURE(revenue) AS DECIMAL(18,2))  AS revenue,
    CAST(MEASURE(order_count) AS BIGINT)     AS order_count
  FROM gold.mv_sales
  GROUP BY country, month
),
oracle AS (
  SELECT
    c.country,
    CAST(DATE_TRUNC('MONTH', f.order_ts) AS DATE)  AS month,
    CAST(SUM(f.amount_eur) AS DECIMAL(18,2))       AS revenue,
    CAST(COUNT(DISTINCT f.order_id) AS BIGINT)     AS order_count
  FROM gold.fct_order_line f
  JOIN gold.dim_customer c
    ON  c.customer_sk = f.customer_sk
    -- the view joins the CURRENT version (see the mv_sales YAML), so the
    -- oracle does too. Join on the interval here and the oracle is red for
    -- every customer who ever moved country — a real question, but a
    -- different assertion, not this one.
    AND c._tech_is_current
  WHERE f.status IS DISTINCT FROM 'CANCELLED'
  GROUP BY 1, 2
)
SELECT 'metric_view_only' AS side, *
FROM (SELECT * FROM from_view EXCEPT ALL SELECT * FROM oracle)
UNION ALL
SELECT 'oracle_only' AS side, *
FROM (SELECT * FROM oracle EXCEPT ALL SELECT * FROM from_view)
Two deliberate asymmetries, and they are where this oracle earns its cost. The view filters with status <> 'CANCELLED'; the oracle filters with IS DISTINCT FROM. A row whose status is NULL is therefore counted by the oracle and dropped by the view, so the gap between them is the exact revenue that three-valued logic is deleting — invisible in the view itself, because a metric view has no rows to count.
The second is the dimension join. gold.mv_sales resolves country through _tech_is_current, which means a customer who moves from AT to DE takes three years of history with them, retroactively. That is a business decision, not a bug, and the oracle must join the way the view joins or it reports the decision as a defect every night. Write the interval-join version as a separate file whose returned rows quantify the shift — then take that number to the business rather than to the engineer.
Why here
The argument for defining revenue once is that every consumer gets the same number. That only holds if the definition is right, and a definition used by five tools is a defect multiplied by five. Unlike a table, a metric view has no rows to inspect — the only way to see what it computes is to compute it another way.
The independence requirement is not process theatre. Measures fail through misunderstood semantics far more often than through broken SQL, and that is exactly what a second author with a different mental model catches.
Doing it
  • Write one oracle per measure — revenue, order_count, average_order_value, active_customers — not one for the view as a whole.
  • Test average_order_value separately: AVG of line amounts and revenue over order_count are different numbers, and the view has to say which it means.
  • Test active_customers on a month containing a cancelled-only customer. Whether they count is a business decision, and this is where you find out it was never made.
  • Write the oracle's filter NULL-safely even though the view's is not. The difference between the two is the measurement of the problem; making them identical hides it.
Embedding it
  • Pair the view's author with the analyst who requested the measure and have the analyst write the oracle unaided. The conversation after the first red result is the workshop.
  • When oracle and view disagree, do not decide who is right — take both to the business owner. Half the time the view is correct and the requirement was wrong.
  • Make the oracle a required artefact for any new measure. A measure without one is an opinion published to five tools.
Defaults
  • One oracle per measure, written by someone other than the view's author.
  • Symmetric EXCEPT ALL with explicit casts on both sides.
  • The oracle joins dimensions exactly as the metric view does. A different join is a different question and belongs in its own file.
  • Oracles run on metric-view change as well as on schedule — a definition moves without any data moving.
Gotchas
  • The author writing their own oracle. The test proves internal consistency, passes forever, and gives false confidence to five downstream tools at once.
  • Type coercion across the set operator. MEASURE() returning DOUBLE against an oracle's DECIMAL(18,2) produces differences of 0.000000001 on every row — red on correct data, and within a fortnight someone adds a tolerance that hides a real break.
  • Testing only the total. Revenue can be right in aggregate and wrong per country when a dimension resolves through the wrong customer version, so the oracle must group by the dimensions people slice by.
  • Metric-view materialization is Public Preview, and metric import from third-party tools is Beta — neither belongs in a sizing estimate. If the oracle suite starts failing intermittently after someone enables materialization, suspect a stale materialization before you suspect the data.

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.

  • Set operators: EXCEPT, INTERSECT, UNIONthe DISTINCT-by-default semantics, and confirmation that set operators match by position rather than by name
  • NULL semanticsread before writing any assertion — the NOT IN and comparison-operator behaviour that makes checks pass on broken rows
  • count_ifthe aggregate that makes the SCD2 one-current assertion three lines instead of three subqueries
  • Constraints on Databricksconfirms primary and foreign keys are informational and what RELY changes — the reason the orphan assertion exists at all
  • Unity Catalog metric viewsthe YAML definition and MEASURE() syntax the oracle is written against
Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.