Data Quality¶
14:02. Airflow DAG: SUCCESS. Kafka lag: 0. ClickHouse insert: confirmed. Then a VP pings the channel — why does the revenue tile show $40M when finance's spreadsheet says $19M? No job failed. No alert fired. Every system you'd check first is green.
What actually happened?
A. A schema change silently coerced a numeric field to NULL. B. A join fanned out and doubled rows. C. A timezone or epoch-unit bug shifted every timestamp. D. At-least-once delivery double-counted events.
Pick one before reading on — or notice that you can't, from green dashboards alone, which is the point. A pipeline can be operationally green and still be wrong, because uptime checks and correctness checks are different instruments; quality is how you catch this kind of silent failure before a human makes a decision on the wrong number.
Related: metadata, Airflow idempotency, incidents.
Silent failure modes (memorise these)¶
| Failure | Why nothing throws | What a check looks like |
|---|---|---|
| New column / missing field | JSON get defaults | Null rate on required columns |
| Type coerce | Spark "null on bad rows" | Row count in vs out, cast-fail counter |
| Timezone / epoch ms vs s | Still a timestamp | max(ts) vs now; distribution of hour-of-day |
| Fan-out join | More rows, job OK | Uniqueness; count vs count distinct |
| Hot tenant dropped by bug | Other tenants fine | Per-customer_id volume vs forecast |
| Duplicate delivery | At-least-once | Unique event_id |
| Partial partition overwrite | Success on 23/24 hours | Completeness by partition |
| Filter pushed wrong | SQL still valid | Sum of amount vs source system |
If your only alert is job failed, you will learn about these from a VP.
Dimensions¶
| Dimension | Question | Example |
|---|---|---|
| Completeness | Are expected rows here? | Today's rows vs 7-day median; all Kafka partitions present |
| Freshness | Is it new enough? | max(timestamp) > now() - 30 min |
| Validity | Domain constraints | status_code IN (...); latency_ms >= 0 |
| Uniqueness | Dupes? | count(*) = count(distinct event_id) |
| Consistency | Cross-field / cross-table | order_total ≈ sum(items); CH agg vs Iceberg within 1% |
| Distribution | Shape | p95 latency jump; country mix; null spike |
Start with completeness, freshness, uniqueness on gold tables. Distribution checks are how you catch the 5.5 h timezone bug (hour-of-day histogram).
Where checks run (this is the architecture)¶
flowchart LR
RAW[Raw] --> Q1[Gate: schema, nulls, types]
Q1 --> T[Transform]
T --> Q2[Gate: business, counts, uniques]
Q2 --> S[Serve CH / UI]
S --> Q3[Watch: freshness, tile probes] | Layer | What belongs | What does not |
|---|---|---|
| In-pipeline (Flink/Spark/ingest) | Schema, required fields, drop/DLQ poison | Heavy cross-table consistency |
| Warehouse / lake (dbt, GE, CH) | Uniques, relationships, volume, recon to source | Blocking every ad-hoc query |
| Serving | Freshness of the tile, row policies | Recomputing all GE suites every 10 s |
| Source | Producer contract tests | Hoping the lake will notice |
Fail fast vs alert
Block the publish of gold fct_orders if uniqueness fails — better an old dashboard than a doubled GMV. Alert but do not block a distribution z-score on a volatile metric unless you have a runbook. Blocking on noisy tests teaches people to disable tests.
In-pipeline checks must be cheap (counters, sample). Warehouse checks can scan yesterday's partition. Do not run a full GE suite on 5M events/s in Flink.
Great Expectations, used honestly¶
GE is a way to version expectations and emit a report. It is not a personality.
validator.expect_column_to_exist("customer_id")
validator.expect_column_values_to_not_be_null("timestamp")
validator.expect_column_values_to_be_between("latency_ms", 0, 60000)
validator.expect_column_unique_value_count_to_be_between("status_code", 1, 20)
Honest use:
- Run on batches (yesterday's Iceberg partition, a CH sample), not on every Kafka record.
- A few dozen expectations per gold table, not 400.
- Fail the Airflow task on critical expectations; warn on others.
- Attach the result to OpenLineage / catalogue (metadata).
- Do not GE the same thing dbt already tests.
Dishonest use: a suite nobody looks at, mostly thresholds that accept 40% nulls, or GE as a substitute for a schema registry.
dbt tests, used honestly¶
models:
- name: clean_events
columns:
- name: event_id
tests: [unique, not_null]
- name: status_code
tests:
- accepted_values:
values: [200, 201, 400, 404, 500, 502, 503]
- name: customer_id
tests:
- not_null
- relationships:
to: ref('customers')
field: customer_id
Honest use:
- Tests on models that publish, not on every staging view.
relationshipsonly when the parent table is complete (CDC lag will false-fail).accepted_valueswill break on a legitimate new status — that is a feature if you want to know; use a warn-severity if product adds codes weekly.- Store test results; page the owner, not
#analytics-random.
dbt tests are warehouse-time. They will not catch a 10-minute ClickHouse outage on the hot path — that is a serving freshness probe.
Anomaly detection (after rules)¶
Rules miss unknown unknowns. A z-score on row count per tenant per hour catches "we dropped tenant 42."
Caveats:
- Weekly seasonality: compare like-for-like weekday, not a 30-day mean that includes weekends.
- Launches and bot attacks look like quality bugs — include a "known spike" path.
- Alert on relative drop per tenant, or whales drown the global z-score.
Do not start here. Start with not_null and partition completeness.
Reconciliation¶
The only consistency check executives believe: compare to the source of truth.
- GMV from Iceberg
fct_ordersvs Postgressum(orders)within 0.5%. - CH
events_aggvs Iceberg daily within 1% (hot path may be late). - Fraud scores count vs Kafka
transaction_idunique count.
When they diverge, lineage tells you which hop. Idempotency (Airflow) tells you whether a rerun is safe.
Worked example: timezone silent fail¶
SaaS events: producer switches from epoch seconds to milliseconds. Spark still divides wrongly. All timestamps sit in 1970 or in the year 56,000. Job is green.
| Check | Result |
|---|---|
not_null timestamp | Pass |
max(timestamp) freshness vs now() | Fail (or insane future) |
| Hour-of-day distribution vs last week | Fail |
| Row count | Pass |
Two cheap checks would have blocked publish. This is why freshness is not optional.
Quality on the five systems¶
| System | Gold check |
|---|---|
| Observability | Parse-fail rate; error ratio not zeroed; CH max(ts) |
| E-commerce | Unique order_id current state; GMV vs Postgres |
| IoT | Gap detection per device; quality flag rate |
| Fraud | Every score has transaction_id; no dup alerts on replay |
| SaaS analytics | Per-tenant volume z-score; RLS not tested here but security |
V1 → V2¶
V1: schema registry + not_null/unique on gold + freshness on the exec dashboard + DLQ counts.
Do not add yet: ML anomaly platform, 500 GE expectations, blocking the firehose on a flaky test.
V2: per-tenant volume, recon to OLTP, quality facets in the catalogue, serving probes on CH tiles.
Apply this at work¶
- Pick one gold metric. Write the silent failures that would not crash the job.
- Add the cheapest check that would catch each.
- Decide block vs alert.
- Page the owner.
- After the next incident, add one check that would have caught it — not a new platform.
Labs do not replace this: when you break ClickHouse ORDER BY, notice that latency is a quality signal for serving, not only for data values.
Severity matrix¶
| Severity | Example | Action |
|---|---|---|
| Sev-1 | Unique order_id fails on gold GMV | Block publish; keep last good partition |
| Sev-2 | Freshness 2× SLO | Page owner; still serve |
| Sev-3 | Distribution z=3 on a volatile metric | Ticket; do not wake 03:00 |
| Mute | Known launch window | Scheduled silence with owner |
If everything is Sev-1, people ignore pages. Metadata owners own severity, not "the platform."
Partition completeness¶
For date-partitioned Iceberg:
Alert if a date is missing or 10× low vs weekday median. A job that "succeeded" on 23 hours is a silent fail. Airflow green is not completeness.
CH: compare count() vs Kafka records_consumed (OpenLineage facets or consumer metrics). 1% slack for at-least-once dupes if you have not deduped.
Sampling in the hot path¶
Flink at 100k/s: keep counters (parse fail, null customer_id, status histogram) in operator state, flush every 10 s to metrics. Do not run GE per event. Sample 0.1% to a "quality topic" for heavier checks.
Warehouse: scan yesterday fully for gold. That is cheap compared to a wrong exec metric.
Tests that lie¶
not_nullon a column that is 90% sentinel""or0.accepted_valuesthat include every integer "to stop the noise."- Volume check against a mean that includes the last outage (you learn to accept outages).
- Uniqueness on a column that is not a key (
user_idon events).
Review tests like production code. Delete lying tests.
Incident retro rule¶
Every silent fail gets one check with an owner. Not a new "quality platform" project. The incidents in this academy are availability; add a data-quality incident of your own (timezone, fan-out join) using the same Alert → Symptoms → Hypothesis template.
In-pipeline vs warehouse vs serving (examples)¶
| Check | Flink | dbt/GE on Iceberg | Grafana/CH |
|---|---|---|---|
| JSON parse fail | counter + DLQ | — | — |
event_id unique | — | unique test on day partition | — |
| GMV vs Postgres | — | recon model | — |
| Tile freshness | — | — | max(ts) probe |
Null customer_id | drop/DLQ | not_null | — |
Do not duplicate not_null in all three. Pick the earliest cheap layer.
Unit of blocking¶
Block a partition publish (yesterday's date=), not the whole table history, not the Kafka topic (unless poison rate is 100%). Serving layer keeps last good tile. Product would rather see 10-minute-old GMV than 2× GMV.
dbt warn vs error¶
error fails the DAG. warn is a log nobody reads unless you page on warn count. Treat warn as debt with a weekly triage. Infinite warns = no tests.
relationships to a CDC table that lags 20 minutes will flap. Use a dedicated recon job with slack, not a per-run FK test.
Great Expectations checkpoints¶
Run GE as an Airflow task after the Spark write, before gold pointer swap. Store HTML/JSON in S3 with the run id. Link from the catalogue. If nobody opened a GE report in 90 days, delete the suite.
Per-tenant quality (SaaS)¶
Global row count can pass while cust_0042 is zero. Group volume checks by customer_id for gold tenants (or all tenants above a size). This is the analytics empty-funnel case.
Timezone and clocks¶
Store UTC. Document it. Check: hour(ts) distribution weekday vs last week; max(ts) < now() + 1h (future) and max(ts) > now() - slo. Epoch ms vs s is a bimodal timestamp histogram. Cheap. Catch it.