Data Quality: Advanced
PayPal published an open-source template for data contracts, and a Friday release on a checkout team shows what one is for. A checkout engineer renames amount_cents to amount and switches it to dollars. With a contract check in the build, the merge fails within minutes and gets fixed before lunch. Without one, Monday's revenue dashboard shows zero for every market, and finance spends the morning finding out why.
A contract for one event
The version follows a major.minor.patch rule. An additive change, such as a new optional refund_reason field, raises it to 1.3.0 and asks nothing of consumers. A breaking change raises it to 2.0.0 and starts the 30-day notice. This team counts any edit to allowed as breaking, added values included, because a consumer that looks up exchange rates by currency fails on a code it has never seen. The amount_cents_meaning line matters as much as the types: a producer who starts including tax keeps every type valid and still moves every revenue figure downstream.
Enforcing it at 2 points
Continuous integration, or CI, is the set of automated checks that run on every proposed code change before it may merge. The contract check in the producer's CI builds a sample event from the changed code, usually inside a unit test, and diffs its fields against the contract file. If a pull request renames amount_cents to amount, the diff shows a required field missing and an undeclared field added. The check classifies that as breaking and fails the build unless the same pull request raises the contract to 2.0.0, which puts the rename in front of reviewers and starts the clock for consumers.
Code reaches production only through the CI check, and each batch of events reaches the curated table only through the gate. Both read the same contract file.
A checkout engineer adds JPY to the allowed currencies and raises the contract from 1.2.0 to 1.3.0 in the same pull request. What does the CI contract check do under this contract's evolution policy?
CI sees code, so it misses every problem the code did not cause. Suppose a payment partner starts routing orders through checkout with currency set to JPY by configuration. No code changed, so CI has nothing to check. The gate runs on each batch of real rows against the guarantees, so it catches the unknown currency, a day that lands 180,000 rows, or a replay that repeats order_id values. A failing gate quarantines the batch, and consumers keep reading the last good partition: the block authority from the beginner lesson, now driven by the contract.
A refunds integration starts writing refunds into the order event stream as negative amount_cents values. Checkout's code has not changed. Which enforcement point stops these rows from reaching orders_curated?
| Pillar | Question | Measured as | Typical fault |
|---|---|---|---|
| Freshness | When did new data last land? | minutes since the newest `loaded_at` | a stalled upstream job |
| Volume | Did the expected amount arrive? | rows per partition against recent history | a dropped file, a doubled load |
| Schema | Did the shape change? | column names and types against yesterday | a removed or retyped column |
| Distribution | Are the values plausible? | null rate, distinct count, percentiles | a unit change, a flood of defaults |
| Lineage | What feeds this table, and what reads it? | upstream and downstream datasets as a graph | where a fault began and who it reaches |
The pillars as an incident order
| Table | Freshness | Volume | Schema | Distribution |
|---|---|---|---|---|
| `revenue_daily` | on time | normal | unchanged | EU revenue is 0 |
| `orders_curated` | on time | 31% below its 28-day median | unchanged | no EU rows |
| `orders_raw` | on time | 412,000 rows, normal | unchanged | new `currency` value `eur` in 31% of rows |
Freshness is green everywhere, so the data arrived. Volume is normal in orders_raw and 31% low one step later, which places the loss in the step that builds orders_curated. That step inner joins orders to exchange rates on currency, and the distribution of orders_raw shows why rows vanish: a checkout release began sending lowercase eur, which matches no rate. Walking lineage downstream from orders_curated then finds a second victim, fraud_features, which no dashboard showed. A contract gate with a fixed currency list would have stopped this batch before orders_curated.
Upstream from the dashboard, the fault first shows at orders_curated. Downstream from that table, fraud_features lost its EU rows too.
The pillars as a coverage map
| Table | Freshness | Volume | Schema | Distribution | Lineage |
|---|---|---|---|---|---|
| `orders_raw` | yes | yes | yes | gap | yes |
| `orders_curated` | yes | gap | yes | gap | yes |
| `revenue_daily` | yes | gap | gap | gap | yes |
| `fraud_features` | gap | gap | yes | gap | yes |
The incident passed through 2 gaps: no distribution check on orders_raw and no volume check on orders_curated. Either one would have fired hours before finance noticed. revenue_daily has 3 gaps of its own, so a fault that started there would surface only on the dashboard.
The sessions_daily dashboard shows average session length doubled overnight. sessions_daily is fresh, its row count matches history and its schema is unchanged. What comes next?
- Promises the partition is published by 06:00 UTC
- Measured from run records: start, end, status
- Broken by stuck tasks, slow inputs, cluster outages
- Improved by retries, concurrency, earlier upstream schedules
- Promises the published partition is correct
- Measured from check results and the corrections log
- Broken by wrong values, missing rows, broken joins
- Improved by contracts, producer-side checks, tuned thresholds
Writing each as a measurable commitment
A commitment names an indicator and sets a target for it over a window. The operational indicator is direct: was the partition of orders_curated published by 06:00 UTC the next day? For quality, passing checks is a weak indicator, because a check says nothing about defects it never tests for. A stronger one comes from the corrections log, a record written whenever anyone restates a published partition. The quality indicator becomes: was the published partition never corrected? A partition the gate holds back costs nothing on quality, since no consumer read it.
| Field | Operational SLA | Quality SLA |
|---|---|---|
| Indicator | published by 06:00 UTC the next day | published and never corrected |
| Target | 89 of 90 days | 88 of 90 partitions |
| Window | rolling 90 days | rolling 90 days |
| Source | `ops.runs` | `ops.corrections` |
| Fixed by | platform on-call | producer and table owner |
On Oct 14 the gate blocks the orders_curated partition at 05:50 for duplicate order_id values. The owner fixes it and the partition publishes at 07:10. It is never corrected afterward. How does Oct 14 count?
- ▸One missing refund: a refund service outage dropped events, and no volume check covered refunds
- ▸One EU join loss: lowercase
eurmatched nofx_ratesrow, and no distribution check coveredcurrency - ▸2 duplicate orders: an upstream replay repeated rows, and the
order_iduniqueness check was warn-only - ▸2 wrong conversions:
fx_ratesloaded a stale day, and no freshness check covered it
Over a quarter, payments_daily meets its operational SLA on 89 of 90 days and its quality SLA on 81 of 90. The team proposes doubling retries and adding a second cluster. What will that change?
Backtest against 90 days of history
A backtest replays a candidate threshold over stored history and counts how often it would have fired. The distributional checks you already run produce one value per metric per day; store them in quality.metric_history and the backtest becomes a query. This one scores each day's row count for orders_curated against the mean and standard deviation of the 8 previous same weekdays, since a Sunday should be compared with other Sundays. It tries 4 values of k, the number of standard deviations that triggers an alert, over the last 90 days.
| k | Alerts | Incidents caught |
|---|---|---|
| 2.0 | 23 | 4 of 4 |
| 2.5 | 11 | 4 of 4 |
| 3.0 | 6 | 3 of 4 |
| 4.0 | 2 | 1 of 4 |
| k | Alerts | Incidents caught |
|---|---|---|
| 2.0 | 12 | 4 of 4 |
| 2.5 | 5 | 4 of 4 |
| 3.0 | 3 | 3 of 4 |
| 4.0 | 1 | 1 of 4 |
Before annotations, only k = 4.0 fits the budget, and it misses 3 of the 4 incidents. Most extra alerts at lower settings fired on events the team already understood, and those events also stretched the baseline for the 8 weeks after them, which made later real drops look smaller.
Annotate the anomalies you already know
| Table | Start | End | Reason |
|---|---|---|---|
| `orders_curated` | 2026-08-12 | 2026-08-12 | email campaign to all customers, volume 1.6x |
| `orders_curated` | 2026-09-18 | 2026-09-19 | sale over 2 days, volume 2.4x |
| `orders_curated` | 2026-10-05 | 2026-10-05 | app outage, real orders down 40% |
After annotation, k = 2.5 catches all 4 incidents with 5 alerts, inside the budget. Give blocking checks a looser k than warning checks. A false warning costs an hour of reading. A false block holds a partition past 06:00 and spends the single spare day in the operational target.
- Backtest every new threshold against 90 days before it goes live
- Compare each day with the same weekday in earlier weeks
- Give every annotation an end date and a named owner
- Copy one k across all 40 checks
- Annotate a day only because it raised an alert
- Let annotated days feed the baseline
After an alert nobody could explain, a colleague annotates orders_curated from 2026-10-20 with no end date. What follows?
On a new dataset the contract comes first and the data is built to match. A legacy table reverses that order. orders_legacy has 23 columns and has loaded nightly for 6 years. It has no written owner, and nobody has listed its readers. A clean contract enforced on day one would block tonight's load. The rollout runs in 4 steps, and each produces something the next one needs.
Step 1: Write down what the table is today
| Profile finding | Entry in contract v1.0.0 |
|---|---|
| `region` null in 4.1% of rows | `region` optional, null rate at most 5% |
| `status` has 7 values, with both `CANCELLED` and `canceled` | all 7 values allowed, as written |
| `amount` is a `double` in dollars, minimum -310.00 | negatives allowed; meaning: refunds are stored as negative orders |
| `amount` maximum 48,200.00 | `amount` at most 50,000.00 |
Step 2: Inventory every consumer
A breaking change cannot be planned without knowing whom it breaks. Query history lists who read the table and which columns they touched. A 90-day search found 13 readers; the 14th, an annual audit export that reads once a year, appeared only when the search covered 13 months. Of the 14, 9 are scheduled jobs and 3 are dashboards; the other 2 are analysts' notebooks. Of those, 5 filter on lowercase canceled only, so normalizing status in place would drop cancellations from their counts without any error. Record an owner and a contact for each consumer; step 4 needs them.
Step 3: Observe before enforcing
| Rule | Violations in 28 days | Decision |
|---|---|---|
| `region` null rate at most 5% | 0 | block |
| `amount` at most 50,000.00 | one, a confirmed 62,000.00 bulk order on Oct 3 | raise the bound in v1.1.0, then block |
| at least 300,000 rows a day | one, on Oct 5 | block, with Oct 5 covered by an `orders_legacy` outage annotation |
| `order_id` unique | 3 | stay warn until the upstream retry replaces its partition instead of appending, then block after a clean window |
Step 4: Migrate behind a new major version
The cleanups go into contract v2.0.0 on a new table, orders_v2, under the same evolution policy as checkout's contract. There amount_cents becomes a bigint and status is normalized to 6 values. region is now required and filled from the delivery address. The legacy job writes both tables from the same input, each behind its own gate. Version 1 is frozen and receives fixes only. Each consumer from the inventory gets a dated move to v2, and orders_legacy is retired once every inventoried consumer has moved and the 30-day notice has passed.
Contract v2.0.0 maps both CANCELLED and canceled to CANCELLED. Of the 14 consumers, 5 filter on lowercase canceled. What keeps their cancellation counts right through the migration?
One job, 2 contracts, 2 gates. Each consumer moves from the v1 path to the v2 path on its own dated plan.
At week 6 of the move, 8 of the 14 consumers read orders_v2. A producer pull request renames region to market and raises the contract from 2.0.0 to 2.1.0. The volume check on orders_v2 has fired 6 times in those 6 weeks, against a budget of about 5 per 90 days. Which plan follows this lesson?
> A grocery delivery company builds driver_payouts nightly from delivery events and pays drivers from it every Monday. The table is 4 years old, has 11 consumers and no contract. Last quarter 3 payouts were restated after the tips service started sending dollars where it had sent cents, while the job met its 05:00 deadline every night and the on-call engineer muted a volume alert that fired on every holiday.
k, bring the muted volume alert back inside what the on-call engineer can investigate.Write contracts producers can't break silently, measure quality as its own SLA, and tune alarms to team capacity.
- Category
- Pipeline Architecture
- Difficulty
- advanced
- Duration
- 38 minutes
- Challenges
- 11 hands-on challenges
Topics covered: Data Contracts and CI Enforcement, Five Pillars of Observability, Quality SLAs vs Ops SLAs, Tuning Thresholds vs History, Contracts on a Legacy Pipeline
Lesson Sections
- Data Contracts and CI Enforcement
A data contract is a versioned file, owned by the team that produces a dataset, stating what every consumer may rely on. It has 3 parts. The schema lists each field with its type and whether it may be null. The guarantees state rules the values obey and delivery promises, such as how soon an event lands after the order completes. The evolution policy says which changes the producer may ship freely, which count as breaking, and how much notice consumers get first. The file sits in the producer's
- Five Pillars of Observability
A contract states what should be true about one dataset. Observability measures what is true across every table, including the many that have no contract yet. Data observability sorts those measurements into 5 pillars, each answering one question about a table. The same 5 serve 2 jobs: an order of questions during an incident, and a grid for deciding what a quality program covers. Lineage is the dependency graph between datasets: for any table, the tables its rows come from and everything built
- Quality SLAs vs Ops SLAs
The orchestration lessons treated an SLA as a promise that a run finishes on time. That is an operational SLA, a commitment about how the pipeline executes. A quality SLA is a commitment about the data a run publishes, and the 2 fail independently. During the EU currency incident every task succeeded and every table landed before 06:00, so the operational SLA held all day while consumers read wrong numbers. Treat them as 2 promises with separate measurements and separate owners, because fixing o
- Tuning Thresholds vs History
A threshold decides how often someone gets pulled into an investigation. Set it too tight and people stop reading alerts; set it too loose and finance finds the incident first. The target is an alarm rate that fits the time the team has. Here, 3 engineers can each work about 5 quality alerts a week. Spread across 40 checks, that allows each check about 5 alerts per 90 days, and every threshold below is judged against that budget. Backtest against 90 days of history The corrections log from the S
- Contracts on a Legacy Pipeline
Step 1: Write down what the table is today Contract v1.0.0 describes the table as it behaves, quirks included, so it passes on its first night and gives every later step a baseline. Profile 90 days to find the facts. Step 2: Inventory every consumer Step 3: Observe before enforcing Run every v1 rule in warn mode for 28 days and record each violation. A rule gets blocking authority once it has run clean or every violation has an explanation. The window from Sep 26 to Oct 23 produced this: Step 4: