gallery unit · data core · bottleneck class G
Schema-drift contract guardrail
“A crashed pipeline gets fixed. A lying one gets quoted in a board meeting.”
dlt (dlthub) engineering blog · “Schema evolution in data pipelines” · Aman Gupta · 2026-06-05
The drift keeps the column present and non-null. So the schema check passes, the null check passes, the row-count check passes — and the number goes quietly wrong.
Six drifts, one landing table
guardrail 6/6 · standard check 1/6 · ~0.1 ms/check
| Drift | What it does downstream | Std check | Contract |
|---|---|---|---|
| Partial unit changecents→dollars, in range | revenue reads $157,512 (true $299,354) | misses | catches |
| Precision truncationfloored to whole dollars | sum drifts; values suspiciously round | misses | catches |
| New enum valueunannounced CAD | a currency bucket nobody handles | misses | catches |
| Boolean re-encoding'true' → 'TRUE'/'1'/'yes' | refund count silently drops to 0 | misses | catches |
| Type wideningint → text, coerced past | the revenue sum can't run | misses | catches |
| Out-of-range scalethe one loud drift | values blow past the max | catches | catches |
The standard check catches exactly one drift — the only one whose values leave the observed range. Every other drift keeps each value individually valid and corrupts the aggregate. The unit-scale one makes the revenue report read $157,512 against a true $299,354: no error, no null, quoted in the meeting.
run it yourself
This runs the real guardrail in your browser on 240 rows of a synthetic ELT landing table. Inject a drift and watch the contract catch what the standard schema / null / row-count check misses — and what it does to the number the report emits. Computed live, not canned.
Baseline: the table as landed. Nothing should fire.
no violations — clean data passes
passed — clean data is valid
revenue $299,354.48 · 24 refunds · 3 currency buckets
Same contract and standard check as the Python harness; verified to reproduce its 6/6-vs-1/6 verdicts on this table. The one drift the standard check catches is the only one that leaves the observed range.
Why the usual gate fails
- A schema check reads the shape. Column present, dtype unchanged, non-null, row count identical, values inside the observed range. Every silent drift preserves all five. The inferred schema signs off.
- The corruption is in meaning, not shape. A currency that split into two spellings, a boolean that grew a third encoding, a cents column that started shipping dollars — each value still looks valid. Only the aggregate is wrong.
The contract pins meaning: the declared dtype, the allowed value domain of each categorical column, a magnitude band for the money column, and a ceiling on how “round” those values may be. A drift that survives shape checks still has to survive the domain, the band, and the precision signature — and these don’t.
standard check : present? non-null? same dtype? in [min,max]? same rowcount? → all pass
contract : currency ∈ {USD,EUR,GBP}? amount mean within ±20%? ≤10% whole-dollar?
→ new "CAD", or a mean off by 47%, or 30% suddenly round → FLAGGEDEvidence
Tier 4 — one landing table, six drifts, scored by the contract guardrail and by a real off-the-shelf control (pandera’s inferred schema at defaults, plus explicit row-count and null-ratio checks). The control is not a strawman: it catches the one drift that leaves the observed range, and only that one. The rest it waves through, and the downstream number moves. Synthetic data (drift needs controlled before/after); stated, not hidden.
make setup && make test && make run # $0, laptop, no GPU, no network
Honest gaps
The contract’s categorical domains are declared, so a genuinely new-but-legitimate category reads as a drift until the contract is updated — that friction is the point, but it is friction. The magnitude and precision bands are fitted from a golden sample and tuned for this table; production needs per-column tuning. And the hardest drift class is out of reach of any per-column contract: a cross-field one — say, amounts that stop being normalized to a common currency while every column stays in its own domain — corrupts the sum with no new value anywhere, and needs a reconciliation total, not a schema. That is the next guardrail, not this one.