Education › Data › Stage 2: Pipelines

Data quality, tests & contracts

Freshness, volume, schema and distribution checks, data contracts, and stopping bad data at the door.

Intermediate ~30 min read Module 8 of 16

A pipeline that runs green and loads wrong numbers is worse than one that fails, because nobody looks at it. Data quality is the practice of making wrongness visible: checks that assert what must be true of a table, run every time it changes, that stop bad data at the door instead of letting it reach a dashboard or a model. It also means agreeing with producers what the data will look like, so that a renamed column upstream is a contract violation caught in their CI, not a silent NULL in your report a week later. This module covers the dimensions of quality worth checking, where to put checks, how to write them so they are useful rather than noisy, data contracts, and what to do when a check fails.

After this module you can
  • Name the quality dimensions (freshness, volume, schema, uniqueness, completeness, distribution, referential integrity) and choose checks for each
  • Place checks at ingestion, after transformation and before publication, with the right severity
  • Write checks that are specific and stable, and quarantine rather than drop bad rows
  • Define and enforce a data contract between a producer and a consumer
  • Handle failures: block, alert with context, root-cause, and turn incidents into new checks

What can be wrong with a table

Data problems cluster into a handful of dimensions, and each has a cheap check. Freshness: the newest row is older than expected. Volume: far fewer or more rows than usual for the period. Schema: a column missing, renamed or of a new type. Uniqueness: duplicate keys. Completeness: NULLs where values are required. Distribution: values outside a plausible range, a category that never appeared before, a mean that jumped. Referential integrity: keys pointing at rows that do not exist. Most incidents are one of these; a table with a check for each rarely surprises anyone.

DimensionExample checkTypical cause when it fails
Freshnessmax(loaded_at) within 6 hoursStuck loader, upstream outage
Volumerow count within 30% of same weekday last weekPartial load, duplicate load, source change
Schemacolumns and types match the contractUpstream migration
Uniquenessorder_id uniqueRetry without idempotency, join fan-out
Completenesscustomer_id not nullNew code path forgetting a field
Distributionamount between 0 and 50,000; status in the known setCurrency change, new enum value, unit error
Referentialevery customer_id exists in dim_customersDimension load lagging the fact
Note

The dimensions map onto the modelling and pipeline rules from earlier modules: grain (uniqueness), SCD invariants (one current row per key), idempotency (volume does not double), event-time partitioning (freshness by event, not by load).

Where checks live

Checks belong at three points. At ingestion, on raw or bronze data: schema and basic validity, to catch upstream changes before they spread; bad rows are quarantined with a reason, not dropped. After transformation, on every model: the dbt tests from the previous module — uniqueness, nullability, accepted values, relationships, and business rules as singular tests. Before publication, on the marts that dashboards and downstream systems consume: freshness, volume and distribution checks that gate the publish step. The severity differs: an ingestion schema change and a mart uniqueness failure should block; a distribution drift might warn and open a ticket.

Ingestion-time checks with a quarantine: rows that fail go to a side table with the failing rule, and the run fails only if the reject rate is unreasonable.
python
import polars as pl

MAX_REJECT_RATE = 0.01


def check_and_split(df: pl.DataFrame) -> tuple[pl.DataFrame, pl.DataFrame]:
    rules = {
        "order_id_null":   pl.col("order_id").is_null(),
        "amount_negative": pl.col("amount") < 0,
        "amount_huge":     pl.col("amount") > 50_000,
        "status_unknown":  ~pl.col("status").is_in(["placed", "paid", "shipped", "refunded"]),
        "future_date":     pl.col("ordered_at") > pl.lit(pl.datetime(2100, 1, 1)),
    }
    tagged = df.with_columns(
        pl.concat_list([pl.when(expr).then(pl.lit(name)).otherwise(None) for name, expr in rules.items()])
          .list.drop_nulls().alias("failed_rules")
    )
    good = tagged.filter(pl.col("failed_rules").list.len() == 0).drop("failed_rules")
    bad = tagged.filter(pl.col("failed_rules").list.len() > 0)
    rate = bad.height / max(df.height, 1)
    if rate > MAX_REJECT_RATE:
        raise RuntimeError(f"reject rate {rate:.2%} exceeds {MAX_REJECT_RATE:.0%}; not loading")
    return good, bad     # caller writes `bad` to orders_quarantine with run id and timestamp

Quarantine matters because dropping rows hides the problem and failing the whole load on one bad row makes the pipeline brittle. A reject-rate threshold separates "a few malformed rows, load and report" from "something upstream broke, stop". The quarantine table gets its own freshness and volume checks; a quarantine that grows quietly is a problem in disguise.

Writing checks that stay useful

A check that fires every Monday because Monday volume is always lower gets muted, and then it never catches anything. Specific, stable checks compare like with like: volume against the same weekday over recent weeks, not against yesterday; freshness against the source's actual delivery schedule; distributions with tolerances derived from history rather than guessed. Anomaly-based checks (learned baselines) help for high-cardinality metrics, but a handful of explicit assertions written by someone who understands the data catch more, with fewer false alarms.

A volume check that respects weekly seasonality, written as a singular dbt test (it must return zero rows).
sql
-- tests/assert_fct_orders_volume_plausible.sql
with daily as (
    select order_date, count(*) as n
    from {{ ref('fct_orders') }}
    where order_date >= current_date - 35
    group by 1
),
baseline as (
    select
        order_date,
        n,
        avg(n) over (partition by extract(dow from order_date)
                     order by order_date rows between 4 preceding and 1 preceding) as same_weekday_avg
    from daily
)
select *
from baseline
where order_date = current_date - 1
  and same_weekday_avg is not null
  and (n < 0.7 * same_weekday_avg or n > 1.5 * same_weekday_avg)
  • Every check has an owner and a runbook line: what it means, first thing to look at.
  • Severity is explicit: error blocks the build and pages; warn records and notifies asynchronously.
  • Checks are versioned with the models they protect and reviewed in the same pull request.
  • Thresholds are numbers in config with a comment explaining where they came from.
  • A check that never fails in six months and could not plausibly fail is a candidate for removal; one that fails weekly is either catching real issues or needs tuning — decide which.

Data contracts

Most quality incidents originate upstream: an application team renames a field, changes a type, or stops populating a column, and the first sign is a broken report days later. A data contract makes the interface explicit: the producer publishes a schema (names, types, nullability, semantics, allowed values), a delivery expectation (frequency, freshness), and a versioning policy; the consumer builds against it; and both sides enforce it in CI — the producer's tests fail if their output no longer matches the contract, and the consumer's ingestion checks fail if the received data does not. Contracts turn a surprise into a pull request conversation.

A contract for the orders feed. Schema, semantics, delivery and change policy, in a file both teams version.
yaml
dataset: shop.orders
owner: shop-backend@acme.example
version: 2
schema:
  - name: order_id
    type: integer
    nullable: false
    unique: true
    description: Natural key from the shop database.
  - name: customer_id
    type: integer
    nullable: false
  - name: ordered_at
    type: timestamp
    nullable: false
    description: Order creation time, UTC.
  - name: status
    type: string
    nullable: false
    enum: [placed, paid, shipped, refunded, cancelled]
  - name: amount_cents
    type: integer
    nullable: false
    description: Order total in minor units; never negative.
delivery:
  frequency: every 15 minutes
  freshness_sla: 1 hour
  partition: ordered_at (date)
changes:
  additive: allowed without version bump (new nullable columns)
  breaking: requires version bump and 30 days notice (rename, type change, removal, new enum value)

Enforce the contract mechanically. The producer generates the schema from their code and diffs it against the contract in CI. The consumer's ingestion step validates incoming data against the same file (the pydantic models from the Python module are one way; a schema registry is another for streaming). When a breaking change is needed, the producer publishes version 2 alongside version 1 for the notice period, exactly like an API.

When a check fails

A failing check is an incident with a small blast radius, and the response follows the SRE track's shape. Block the affected data from reaching consumers: dbt build skips dependents; a publish gate holds the mart; a dashboard shows its last good version with a banner. Alert with context: table, check, observed versus expected value, the run, a link to the data. Root-cause from the top of the lineage down: was the source late, did a load duplicate, did a schema change. Fix and rerun with the idempotent backfill. Record it, and add the check that would have caught it earlier if one did not exist. A short log of quality incidents, with time to detect and time to fix, is the quality program's own dashboard.

Tip

Show the freshness and the last check status on the dashboard itself. Consumers who can see "data as of 06:12, all checks passed" trust the numbers; consumers who cannot will build their own spreadsheet.

Hands-on practice

Guard a pipeline end to end

  1. Add the ingestion check_and_split step to the batch pipeline from earlier modules, writing rejects to an orders_quarantine table with the run id. Feed it a file with three bad rows and confirm they land in quarantine while the rest loads.
  2. Raise the bad-row count above 1% and confirm the run fails with a clear message before loading anything.
  3. In the dbt project, add the seasonal volume singular test and the standard column tests. Load a duplicated day of orders and watch dbt build fail the mart and skip its dependents.
  4. Write the orders data contract as YAML. Write a ten-line Python script that validates a Parquet file's schema against it (names, types, nullability) and run it in the ingestion step.
  5. Simulate a breaking change (rename amount_cents to total_cents in the source) and confirm the contract check fails at ingestion with the column named in the error.
  6. Add freshness and volume checks on the quarantine table itself.
  7. Write a one-page runbook entry for the volume check: meaning, first three things to look at, how to rerun.
Cheat sheet

Data quality, tests & contracts — at a glance

Main things to focus on

  • Seven dimensions: freshness, volume, schema, uniqueness, completeness, distribution, referential integrity
  • Checks at ingestion (quarantine, reject-rate threshold), after transformation (dbt tests), before publication (gates)
  • Compare like with like: same weekday baselines, source delivery schedules, historical tolerances
  • Every check: owner, severity, runbook line, versioned with the model
  • Data contracts: schema, semantics, delivery, change policy; enforced in both producer and consumer CI
  • On failure: block, alert with context, root-cause down the lineage, fix and backfill, add the missing check

Standard checks

max(loaded_at) > now() - interval '6 hours'Freshness
count(*) vs avg of same weekday, last 4 weeks, ±30%Volume with seasonality
columns/types == contractSchema
unique, not_null, accepted_values, relationshipsdbt generic tests
singular test returning zero rowsBusiness rules
dbt_utils.accepted_range / expression_is_trueDistribution bounds

Ingestion and quarantine

rules = {name: expr}; failed_rules list per rowTag rows with every failing rule
quarantine table: row, failed_rules, run_id, tsNever drop silently
fail run if reject_rate > thresholdFew bad rows load; a broken source stops
checks on the quarantine table tooA growing quarantine is an incident

Contracts

schema: name, type, nullable, unique, enum, descriptionThe interface
delivery: frequency, freshness_sla, partitionThe service level
changes: additive allowed; breaking = version bump + noticeEvolution policy
producer CI diffs output schema vs contractCatch it before it ships
consumer validates on ingest (pydantic / schema registry)Catch it before it spreads

Failure handling

dbt build skips dependents on test failureBlock by default
alert: table, check, observed vs expected, run, linkContext, not just red
root-cause top-down through lineageSource late? Load duplicated? Schema changed?
fix, backfill idempotently, add the missing checkClose the loop
freshness + check status on the dashboardVisible trust

Common pitfalls

  • Volume checks against yesterday, firing every Monday until someone mutes them.
  • Dropping bad rows silently instead of quarantining them with a reason.
  • Failing the whole load on one malformed row, making the pipeline too brittle to keep.
  • Checks with no owner or runbook, so a failure is a mystery to whoever is on call.
  • Discovering upstream schema changes from a broken dashboard instead of a contract check in the producer's CI.
  • Publishing marts with a separate test step that runs after consumers have already read them.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →