Data engineering · dbt contracts

warehouse-quality-gate

A dbt contract that stops a bad batch before the mart is rebuilt from it. The sabotaged batch loads without a single error and reports revenue of $4,905,051; a clean batch from the same generator reports $395,751. Almost all of the gap is one row — order 401 at 4,500,000 — and a plain pipeline waves it straight through. The contract fails 12 tests and skips the mart build, so the mart keeps the last good run’s numbers.

Public · synthetic demo
The problem

"It loaded without errors" is not a quality signal.

Every one of the twelve defects in the sabotaged batch is a defect that survives a plain load. Nothing throws. A duplicated customer file, an amount thousands of times larger than any other order, a status with a capital D — the rows land, the job exits 0, and the number a stakeholder reads is wrong by an order of magnitude.

The money shot

Two batches, loaded two ways

① Plain load vs the contract
runrows loadedrevenue reportederrorstests failed
Plain load — clean batch900$395,751.280—
Plain load — sabotaged901$4,905,051.180—
Contract — clean batch900$395,751.2800 of 15
Contract — sabotaged901 (staging)mart not rebuilt1212 of 15
12/12planted defects caught
$4.5Mcarried by one row a plain load ships
0false alarms on the clean batch
② Two defects worth expanding on

D9 tests the raw value, not the normalised one. It is tempting to lower() the status in staging and test the clean column — but then the test can never fail, and the downstream filter doing an exact match on 'delivered' still silently drops the row. The model exposes both status_raw and status_normalised, and the contract tests status_raw — which is only trimmed, not untouched (see the limitations below).

D7 is a contract, not a bug. Nothing is wrong with an EUR order. What is wrong is that fct_revenue_daily sums amount without conversion, so mixing currencies makes the total meaningless. The single-currency rule is written down as a test precisely because the assumption lives in a model somewhere else.

③ The twelve, and what caught each
#what arrivedcaught by
D1customer_id 8 duplicated — a re-sent file loaded twiceunique
D2customer 16 email arrived blank (the seed loader reads it as NULL)not_null
D3country USA instead of ISO-2 USaccepted_values
D4signup_date 2027-06-01, in the futurenot_in_future (compares with today’s date)
D5order 101 references customer 99999, which does not existrelationships
D6order 201 amount is -450.0 on an order that is not a refundnon_negative
D7orders 301–302 switched to EUR while the mart sums as USDaccepted_values
D8order 401 amount 4,500,000 — thousands of times any other orderwithin_magnitude
D9status Delivered with a capital Daccepted_values on raw
D10order 601 present twice — double revenue recognitionunique
D11order 701 dated before the reporting window openswithin_reporting_window
D12order 801 amount arrived empty (NULL); SUM silently skips itnot_null
Reproduce it
python3 -m venv .venv && . .venv/bin/activate
pip install dbt-core dbt-duckdb
python3 scripts/make_batches.py     # regenerates both batches, seed is fixed
./scripts/run_evidence.sh           # runs the contract over each, writes evidence/
python3 scripts/naive_vs_gate.py    # shows what a plain load reports instead

The warehouse here is DuckDB so the whole thing runs on a laptop. The staging models use DuckDB SQL (try_cast, cast(… as double)); moving to another warehouse means porting those, not only changing profiles.yml. Last checked with dbt-core 1.12.5 and dbt-duckdb 1.11.0.

Honest limitations
· Synthetic batches (900 rows, fixed seed) — a demonstrator of the method, not a benchmark.
· Contracts stop bad data. They do not tell you a job never ran, or that a scraper returned
  an empty page — those are pipeline-heartbeat and scraper-canary.
· The twelve defects are the ones that survive a plain load. Defects that crash the load
  are already visible and are deliberately out of scope.
· The magnitude test is a fixed ceiling (100,000). A real ×100 unit slip on orders under
  $900 lands under 90,000 and passes; that needs a check relative to the column’s history.
· When the contract fails, the mart is not rebuilt — it keeps the last good run’s
  numbers, and nothing tells the reader. Pair it with a freshness check.
· status_raw is trim(status) and country is tested after
  upper(trim(…)), so shipped  and us pass. Test the value as it arrived.
· An empty batch (with its column types declared) passes all fifteen tests. Volume is not checked.
This is a synthetic sample demonstrating the method. Inspect the checks, the fixtures, and the reproducible evidence ↗