Search Tech Journey

Find topics, journeys and posts

6-month learning plan53 / 130
back to blog
data engineeringintermediate 55m read

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.

🗄️Data EngineeringM05 · Data Engineering· Session 053 of 130 90 min

🎯 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.

You will be able to
  • 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

Data quality is a smoke alarm, not a sprinkler
🌍 Real world

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.

💻 Code world

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

Every DQ tool ultimately checks these
  • 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

  1. 2011
    ‘Data quality’ was a QA team's problem
    Nightly SQL scripts, alerts to a shared inbox nobody read. Failures caught by dashboard viewers.
  2. 2017
    Great Expectations open-sourced
    First widely adopted OSS lib to express DQ as testable expectations in code + JSON.
  3. 2019
    ‘Data observability’ named
    Monte Carlo, Bigeye, Databand raise huge rounds. Five pillars framework becomes industry vocabulary.
  4. 2020
    dbt tests everywhere
    Analytics engineering ships schema tests with every model. ‘Test in prod’ becomes normal.
  5. 2023
    Data contracts + circuit breakers
    Contracts 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

11
Compute metric

Run one SQL/DF query against the new snapshot. e.g. `SELECT COUNT(*) FROM orders WHERE dt = today`.

22
Compare to expectation

Threshold (`> 900k`), band (`within ±20% of 7d avg`), or contract (`schema == v3`).

33
Emit result event

One structured event: {check_id, severity, value, expected, ts, snapshot_version}. Store forever.

44
Route by severity

critical → page, warn → ticket, info → append to weekly digest.

55
Circuit-break or continue

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

Metric definitions
YAML/Python declaring what to measure. Version-controlled, code-reviewed like any other code.
define
Execution engine
GX / soda-core / dbt-tests / SQLMesh — runs the query against the warehouse or lakehouse.
run
Result store
A `dq_results` table (or equivalent). Every check emits one row: passed/failed, value, expected, run_ts.
store
Router / alerter
Reads recent failures, dedupes, applies severity policy, fires PagerDuty / Slack / GitHub issue.
route
Dashboard + docs
A visible surface: pass rate per table, MTTR per severity, top 10 flappy checks. Data Docs, Elementary, Metaplane.
observe

Tool families you'll encounter

Rule-based (GX, soda-core, dbt tests)

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’
Statistical (Monte Carlo, Bigeye, Metaplane)

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
Contract-based (data contracts, protobuf schemas)

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

Common misconception
✗ What most people think

"Data quality is about validating incoming data. If I add not-null and range checks at ingestion, my data is trustworthy."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.

From first principles
Start with the question

Why can data quality never be fully solved by tests at the boundary? This sounds defeatist — it is actually a structural result.

  1. 1
    A 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
  2. 2
    The 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
  3. 3
    So 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
  4. 4
    Therefore 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
  5. 5
    Minimising 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

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.

Mental modelSmoke detectors, not a fireproof building

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 warn from error deliberately. 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.
🔔 Fires when you see

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.

The tradeoff

A quality check fails mid-pipeline. Do you halt the pipeline, or publish the data with the failure recorded?

Hard fail — block publication
+ you gain no consumer ever sees known-bad data. Decisions are never made on numbers you already knew were wrong, and the incident cannot silently propagate into downstream models, reports and ML features.
− you pay you converted a correctness problem into an outage. Consumers now see stale data or nothing, which for many use cases is worse than slightly-wrong data, and one flaky check on a leaf table can block an entire DAG.
pick when when the data feeds automated decisions, money movement, or regulatory reporting — anywhere a wrong number is more expensive than a missing one
Publish and alert
+ you gain availability is preserved and consumers with looser needs keep working. The team retains the option to judge severity instead of having it decided by a threshold set months ago.
− you pay bad data is now in production and in every downstream cache, extract and screenshot. Cleanup requires finding everyone who consumed it, and alerts routinely go unread for hours.
pick when exploratory, trend-level or internal-analytics data where a directionally-correct-but-stale number still beats a blank dashboard
Publish with a visible quality signal
+ you gain consumers see the data and its status together — a freshness or health badge on the dashboard, or a status column in the table — so the judgement is made by the person who knows the use case.
− you pay requires consumer-side cooperation and UI work that rarely gets prioritised, and humans habituate: a badge that is amber twice a week becomes invisible within a month.
pick when when a single table serves consumers with genuinely different tolerances, and you can actually get the signal rendered where they look
What a senior engineer actually does

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 paged

Anatomy of the script

What each block teaches

build_fixture(bad=...)
Deliberately corrupts the table three ways: fewer rows (volume), stale timestamps (freshness), and negative amounts (distribution). This is what a good DQ test suite looks like — one bad-data generator per check.
fixture
CheckResult dataclass
Structured emission. This is the row you'd persist to `dq_results`. Never let checks return booleans without context — you cannot debug them later.
schema
freshness()
The most valuable single check in data engineering. Compares `MAX(ingested_at)` against wall clock. Detects upstream stalls before anyone opens a dashboard.
freshness
volume() with tolerance band
Static thresholds fail on seasonal data. A band relative to expected value (or 7-day moving average in production) survives Mondays and holidays.
volume
schema(contract=...)
The contract is a dict you check into Git. When it changes, the PR reviewer sees it. This is data-contracts-lite before you adopt a full framework.
schema
distribution_amount()
A row-level rule dressed as a rate. Real distribution monitors use `abs(z-score) > 3` against a rolling window — this is the toy version.
distribution
route() + exit code
The pipeline runner reads the exit code and decides to continue or halt. This is how a ‘circuit breaker’ becomes real: your Airflow DAG sees exit=2 and marks downstream as skipped.
route
Try itTurn the toy engine into Great Expectations

Install and initialise Great Expectations:

pip install great_expectations
great_expectations init

Then 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.

💡 Hint · Focus on Expectations you can name: expect_column_to_exist, expect_column_values_to_not_be_null, expect_column_values_to_be_between, expect_table_row_count_to_be_between. That's already the whole freshness/volume/schema/distribution family.

(d) Production reality · 15 min

War story Airbnb· 20201000s of production tables, hundreds of on-call engineers
🔥 What broke

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.

🧯 The fix
Airbnb built Wall (later spun out ideas into open-source ‘Dataportal’). Every table got auto-generated freshness + volume monitors from usage patterns, and owners were required to acknowledge or snooze alerts within an SLA. Silent outages dropped 70 % in six months.
🎓 Lesson to steal
Coverage matters more than cleverness. Auto-generate boring freshness + volume checks for every table before you invest in fancy statistical monitors on your favorite five.
Post-mortem
War story Uber· 2019Michelangelo ML platform, 100+ models in prod
🔥 What broke
A pricing model started returning strange fares on a Tuesday morning. Root cause: two weeks earlier, an upstream ingestion job silently began writing 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.
🧯 The fix
Uber added schema hash checks to every feature ingestion: the exact Avro schema of every incoming batch is hashed and compared against the registered feature schema. Any drift halts ingestion and pages the producer team, not the model team.
🎓 Lesson to steal
‘It parsed’ ≠ ‘it's correct’. Enforce a schema contract at the ingest boundary, not at the consumer, or debugging becomes archaeology.
Post-mortem
War story Common failure mode · everywherethe ‘alert-fatigue death spiral’
🔥 What broke
A team ships 300 DQ checks on day one. Half are miscalibrated; the on-call rotation gets 40 pages a week; within a month, they mute the channel; within three months, real failures go unnoticed for days.
🧯 The fix

Three rules that recover the signal:

  1. Every alert must map to a runbook. No runbook, no alert.
  2. Every check has a severity budget — critical alerts must fire fewer than 1×/week per team, or they're auto-downgraded.
  3. Weekly review: top 5 flappy checks either get tightened or deleted. No orphan checks.
🎓 Lesson to steal
DQ checks are code. Un-owned code becomes debt. Un-owned alerts become noise. Tie every check to a team and a runbook or it will be muted within a quarter.

Where this shows up in the rest of the plan

Data quality is a load-bearing skill for every data role
S052 · Lakehouse
Freshness + schema checks read directly off Delta/Iceberg commit metadata.
S054 · Governance & cost
DQ results feed the lineage graph and let you attribute an outage to owning teams.
S089 · Feature store
The same four pillars apply to features — plus training-serving skew.
S095 · ML monitoring
Model input drift is a data-distribution problem in disguise.
S110 · Observability & SRE
DQ alerts flow through the same PagerDuty/Slack routing as service incidents.
S128 · Data platform capstone
Every production data platform in this series lists DQ as a top-3 requirement, next to catalog and orchestration.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Move on when you can teach these without notes:

  1. What are the four DQ pillars and why did the industry converge on them?
  2. What is the difference between an expectation and a monitor, and when do you use each?
  3. 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.