S053 · Data Quality — Freshness, Volume, Schema, Distribution
Trust is a bag of four numbers: is the data late, missing, wrong-shaped, or weird? Learn the four checks every serious pipeline runs, wire them into a real DAG, and see the outages they catch before executives do.
🎯 Instrument a table with the four canonical data-quality checks (freshness, volume, schema, distribution), fail a pipeline on a real regression, and design an escalation policy no on-call will hate.
Why this session exists
Every senior data engineer has been called at 2am about a dashboard that "went to zero" — and 90 % of the time, the model is fine, the SQL is fine, and the upstream data quietly stopped arriving four hours ago. Data quality (DQ) monitoring is the seatbelt: cheap to install, saves careers when the crash comes. The industry converged on a small, boring set of four checks — freshness, volume, schema, distribution — and this session gets them running against a real table.
- Define the four DQ pillars (freshness, volume, schema, distribution) with one example failure each.
- Write a Great Expectations / soda-core check that fails a pipeline on a volume drop of >30 %.
- Explain the difference between validators (row-level rules) and monitors (statistical drift).
- Design an alert routing policy that pages for `critical`, ticket for `warn`, silence for `info`.
- Diagnose a false-positive DQ alert in under 5 minutes using the ‘four-questions’ debugging checklist.
Prerequisites
- S050 · Streaming pipelines — you know what a source, sink, and DAG are.
- S052 · Lakehouse — checks run against Delta/Iceberg snapshots.
- S039 · SQL joins & aggregates — most checks are just a SELECT with a threshold.
(a) Intuition · 5 min
You don't run a fire suppression system 24/7. You install cheap sensors — smoke, heat, CO — that scream when a threshold is crossed. When the alarm goes off, a human decides whether it's toast burning or a real fire.
Nobody argues about the value of smoke alarms because they've saved millions of lives at a cost of $30 and a battery change once a year.
DQ checks are the same. Each one is a cheap query that runs after a pipeline step, compares a metric to an expected range, and shouts if it's off. When it shouts, an on-call decides: real data outage or false alarm?
Skip DQ and you're the finance team discovering that "monthly revenue" was under-reported by 12 % for six weeks because a Kafka producer dropped a field. That's the fire you don't want.
The four (five) pillars
- Freshness — is the newest row recent enough? (`MAX(ingested_at) > now() - 2h`)
- Volume — is today's row count within the historical band? (`abs(today - avg_last_7d) / avg_last_7d < 0.3`)
- Schema — did any column change type, become nullable, or disappear? (`describe table` diff vs contract)
- Distribution — did the mean, null rate, or unique count of a key column drift outside its normal range?
- Lineage (bonus) — do we know which downstream reports break when this table breaks? (attribution, not detection)
How the industry got here
- 2011‘Data quality’ was a QA team's problemNightly SQL scripts, alerts to a shared inbox nobody read. Failures caught by dashboard viewers.
- 2017Great Expectations open-sourcedFirst widely adopted OSS lib to express DQ as testable expectations in code + JSON.
- 2019‘Data observability’ namedMonte Carlo, Bigeye, Databand raise huge rounds. Five pillars framework becomes industry vocabulary.
- 2020dbt tests everywhereAnalytics engineering ships schema tests with every model. ‘Test in prod’ becomes normal.
- 2023Data contracts + circuit breakersContracts move upstream (schema at the producer). Pipelines auto-halt on breach instead of paging humans.
(b) Visual walkthrough · 15 min
Where DQ checks live in a pipeline
Note the placement: DQ runs after each stage, not just at the end. Catching a bad batch at ingest is 10× cheaper than catching it after a 4-hour transform.
A DQ check's lifecycle
Run one SQL/DF query against the new snapshot. e.g. `SELECT COUNT(*) FROM orders WHERE dt = today`.
Threshold (`> 900k`), band (`within ±20% of 7d avg`), or contract (`schema == v3`).
One structured event: {check_id, severity, value, expected, ts, snapshot_version}. Store forever.
critical → page, warn → ticket, info → append to weekly digest.
For critical: block downstream jobs and roll back to the previous snapshot. For warn: continue but flag.
The five layers of a DQ platform
What you're actually assembling
Tool families you'll encounter
You declare the rule; it runs it
- Cheap, deterministic, easy to review
- You must know what bug to look for
- Great for schema, freshness, business invariants
- Weak for ‘something changed but nothing broke a rule’
It learns baselines; you set sensitivity
- Auto-detects drift you didn't foresee
- Requires history + tuning; noisy at first
- Great for distribution, null rate, unique count drift
- Costs money and adds an outside vendor to your critical path
Reject bad data at the producer
- Shifts DQ left — never lands in the lake in the first place
- Requires producer-side buy-in (org problem, not tech)
- Great for schema evolution, event streams, PII tagging
- Slower to introduce; needs governance culture
"Data quality is about validating incoming data. If I add not-null and range checks at ingestion, my data is trustworthy."
Schema-level validation catches malformed data. Almost every incident that actually reaches a dashboard is well-formed and wrong: a currency silently switching units, a join fanning out, an upstream team changing an enum's meaning, a partition that simply never arrived. Every value passes every column check; the table is still a lie.
The myth is sticky because ingestion checks catch the first bugs you meet — a null where you expected a value, a string in a numeric column. Those are real and validation genuinely fixes them, so the approach feels complete. It fails because it validates each row in isolation, while the expensive failures are properties of the distribution, of relationships between tables, or of the absence of rows. A row that never arrived cannot fail a row-level check.
Write a check that has no row-level equivalent — this is the class that catches real incidents:
-- volume: did today look like the last 7 days?
select
count(*) as today,
avg(cnt) over () as recent_avg
from daily_counts
where day >= current_date - 7;
-- referential: orders pointing at customers that do not exist
select count(*) from orders o
left join customers c on o.customer_id = c.id
where c.id is null;Neither is expressible as a constraint on a single row, and both fire on incidents that per-column validation waves through.
Why can data quality never be fully solved by tests at the boundary? This sounds defeatist — it is actually a structural result.
- 1A test can only assert a property you thought to state. It converts a known failure mode into an alert.forced by · assertions are written by humans against an explicit expectation
- 2The space of ways data can be wrong is not enumerable in advance, because it includes semantic changes made by upstream systems you do not control.forced by · an upstream team can redefine what a field means without changing its type, and no schema encodes meaning
- 3So no finite test suite closes the space. Every incident you have not yet had is, by definition, untested.forced by · tests are written after understanding a failure, and understanding usually arrives via the failure
- 4Therefore the objective must shift from prevention to detection latency: not "will this ever be wrong" but "how long between wrong and known".forced by · if you cannot bound the set of failures, you can still bound the time they stay invisible
- 5Minimising detection latency favours cheap broad signals — freshness, row-count deltas, null-rate drift, distribution shift — over expensive narrow ones, because broad signals fire on failures nobody predicted.forced by · an unpredicted failure can only be caught by a check that was not aimed at it specifically
Therefore a mature data quality practice is layered: a small number of hard assertions on invariants you truly own (primary key uniqueness, referential integrity), plus broad anomaly monitoring for everything you don't. The first layer prevents; the second layer bounds how long you are wrong.
And note what this predicts: freshness and volume checks — the two cheapest and least "intelligent" monitors you can write — will catch a disproportionate share of your real incidents, because pipeline failures overwhelmingly manifest as "data is late" or "data is missing" before they manifest as anything subtler. If you have budget for exactly two checks per table, those are the two.
You cannot make data unable to be wrong. You can make wrongness loud and early. Picture every table with two detectors on the ceiling: one asks "did anything arrive recently?" and one asks "did roughly the expected amount arrive?". Everything else is a specialised sensor you add after a specific fire.
The metric that matters is not test count. It is the time between a defect entering the warehouse and a human knowing about it.
- Six dimensions, and they are not interchangeable: completeness (is it all there), accuracy (does it match reality), consistency (do the copies agree), timeliness (is it current), validity (does it satisfy the rules), uniqueness (is it duplicated). Most teams test validity and uniqueness and monitor none of the rest.
- Test at the boundary you own. Assert hard constraints on your own outputs; monitor for drift on other people's inputs. Hard-failing on an upstream you cannot fix just teaches the team to ignore alerts.
- Separate
warnfromerrordeliberately. An error must stop the pipeline and block downstream consumption; a warn must have a named owner. A severity level nobody acts on is worse than no check, because it manufactures alert fatigue. - Quarantine beats halting. Route bad rows to a side table and continue with the good ones where the domain allows it — a pipeline that stops entirely on one malformed record converts a data problem into an availability problem.
Fire this model when you see: a dashboard number that "looks off" · a table with 40 column tests and no freshness check · a nightly job that "succeeded" but produced zero rows · duplicated revenue after an upstream replay · a metric that changed the day an upstream team deployed.
A quality check fails mid-pipeline. Do you halt the pipeline, or publish the data with the failure recorded?
Tier it by consequence, not by table. Financial, regulatory and ML-feature paths hard-fail; exploratory analytics publishes with an alert. Encode the tier in metadata next to the table so the behaviour is a property of the data's use, not of whoever wrote the check.
The failure mode to avoid above all others is the middle state everyone drifts into: checks that fire, nobody owns, and everyone has learned to ignore. That is strictly worse than having no checks, because it costs the same to run and creates a false sense of coverage. If a check has fired ten times and no one has ever acted on it, delete it or fix it — do not leave it running.
(c) Hands-on · 25 min
We'll build a tiny DQ engine from scratch — no framework — so you see exactly what the fancy tools do. Then swap in Great Expectations at the end.
"""
dq_demo.py — Four canonical checks on a local Parquet ‘orders’ table.
Run:
pip install pandas pyarrow
python dq_demo.py
"""
from __future__ import annotations
import json
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Callable
import pandas as pd
# ---------- fixture: a fake day-of-orders table ----------
TABLE = Path("/tmp/dq_demo_orders.parquet")
def build_fixture(bad: bool = False) -> pd.DataFrame:
now = datetime.now(timezone.utc)
rows = [
{"order_id": i, "amount": (i % 50) + 1.0, "customer": f"c{i % 200}",
"ingested_at": now - timedelta(minutes=i % 60)}
for i in range(5000)
]
df = pd.DataFrame(rows)
if bad:
# Simulate three regressions: stale, thin, and skewed.
df = df.head(500) # volume drop
df["ingested_at"] = now - timedelta(hours=6) # stale
df.loc[df.index[:100], "amount"] = -1 # negative $$$
df.to_parquet(TABLE)
return df
# ---------- check framework (30 lines is all you need) ----------
@dataclass
class CheckResult:
check_id: str
passed: bool
value: float | str | None
expected: str
severity: str # "critical" | "warn" | "info"
run_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def freshness(df: pd.DataFrame, max_lag_min: int = 120) -> CheckResult:
lag_min = (datetime.now(timezone.utc) - df["ingested_at"].max()).total_seconds() / 60
return CheckResult(
check_id="freshness.orders.ingested_at",
passed=lag_min <= max_lag_min,
value=round(lag_min, 1),
expected=f"<= {max_lag_min} min",
severity="critical",
)
def volume(df: pd.DataFrame, expected: int = 5000, tolerance: float = 0.3) -> CheckResult:
n = len(df)
lo, hi = expected * (1 - tolerance), expected * (1 + tolerance)
return CheckResult(
check_id="volume.orders.rowcount",
passed=lo <= n <= hi,
value=n,
expected=f"between {int(lo)} and {int(hi)}",
severity="critical",
)
def schema(df: pd.DataFrame, contract: dict[str, str]) -> CheckResult:
actual = {c: str(t) for c, t in df.dtypes.items()}
missing = [c for c in contract if c not in actual]
wrong_type = [c for c in contract if c in actual and not actual[c].startswith(contract[c])]
ok = not missing and not wrong_type
return CheckResult(
check_id="schema.orders.contract_v3",
passed=ok,
value=json.dumps({"missing": missing, "wrong_type": wrong_type}),
expected=json.dumps(contract),
severity="critical",
)
def distribution_amount(df: pd.DataFrame) -> CheckResult:
neg_rate = (df["amount"] < 0).mean()
return CheckResult(
check_id="distribution.orders.amount.non_negative",
passed=neg_rate == 0,
value=round(neg_rate, 4),
expected="0.0",
severity="warn",
)
CHECKS: list[Callable[[pd.DataFrame], CheckResult]] = [
freshness,
volume,
lambda df: schema(df, {"order_id": "int", "amount": "float", "customer": "object", "ingested_at": "datetime"}),
distribution_amount,
]
# ---------- runner ----------
def run(df: pd.DataFrame) -> list[CheckResult]:
results = [c(df) for c in CHECKS]
for r in results:
icon = "✅" if r.passed else "❌"
print(f"{icon} [{r.severity:>8}] {r.check_id}: value={r.value} expected={r.expected}")
return results
def route(results: list[CheckResult]) -> int:
"""Return exit code: 0 clean, 1 warn, 2 critical."""
failed = [r for r in results if not r.passed]
if any(r.severity == "critical" for r in failed):
print("\n🚨 PAGE on-call — critical DQ failure. Halting downstream.")
return 2
if failed:
print("\n⚠️ Ticket filed — non-blocking warn.")
return 1
print("\n🟢 All checks green.")
return 0
def main() -> None:
mode = sys.argv[1] if len(sys.argv) > 1 else "good"
df = build_fixture(bad=(mode == "bad"))
exit_code = route(run(df))
sys.exit(exit_code)
if __name__ == "__main__":
main()Run both modes:
python dq_demo.py good # exits 0, all green
python dq_demo.py bad # exits 2, three failures, on-call pagedAnatomy of the script
What each block teaches
Install and initialise Great Expectations:
pip install great_expectations
great_expectations initThen wire it to your Parquet file:
import great_expectations as gx
context = gx.get_context()
source = context.sources.add_pandas("local")
asset = source.add_parquet_asset("orders", filepath_or_buffer="/tmp/dq_demo_orders.parquet")
batch = asset.build_batch_request()
suite = context.add_or_update_expectation_suite("orders_suite")
# The exact same four checks, expressed as expectations:
validator = context.get_validator(batch_request=batch, expectation_suite=suite)
validator.expect_column_values_to_not_be_null("order_id")
validator.expect_table_row_count_to_be_between(min_value=3500, max_value=6500)
validator.expect_column_values_to_be_between("amount", min_value=0)
validator.save_expectation_suite()
result = validator.validate()
print(result.success)The vocabulary changes, the shape doesn't. That's the point.
(d) Production reality · 15 min
Airbnb's data warehouse had grown to thousands of tables, each with an ad-hoc SQL sanity check written by whoever built the table years earlier. Failures went to shared inboxes; nobody read them; two-week silent data outages were common.
The internal ‘data trust’ audit found that <10 % of tables had any monitoring at all, and among those, most alerts were ignored.
city_id as a string ("SF") instead of the enum int (1). The feature pipeline coerced it via hash() — no error, wrong bucket, garbage features flowing into the model.Three rules that recover the signal:
- Every alert must map to a runbook. No runbook, no alert.
- Every check has a severity budget — critical alerts must fire fewer than 1×/week per team, or they're auto-downgraded.
- Weekly review: top 5 flappy checks either get tightened or deleted. No orphan checks.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Move on when you can teach these without notes:
- What are the four DQ pillars and why did the industry converge on them?
- What is the difference between an expectation and a monitor, and when do you use each?
- What's the fastest way to prevent alert fatigue in a DQ program?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.