02 · Data Quality Checks & Validation¶
A pipeline that runs successfully but loads garbage is worse than one that
fails loudly — the failure at least gets noticed. This module builds a small
but real validation layer using pandas, the kind of checks you'd run
between every Bronze→Silver or Silver→Gold hop.
What actually ran
Reasoned through step by step against the real pandas API, not
executed in a live interpreter — output shapes match documented pandas
behavior precisely.
The dataset¶
import pandas as pd
df = pd.DataFrame([
{"order_id": 1, "customer": "alice", "amount": 120.50, "email": "alice@shop.com", "country": "US"},
{"order_id": 2, "customer": "bob", "amount": -15.00, "email": "bob@shop.com", "country": "US"},
{"order_id": 3, "customer": None, "amount": 45.25, "email": "not-an-email", "country": "US"},
{"order_id": 4, "customer": "dana", "amount": 60.00, "email": "dana@shop.com", "country": "ZZ"},
{"order_id": 1, "customer": "alice", "amount": 120.50, "email": "alice@shop.com", "country": "US"},
])
print(df)
order_id customer amount email country
0 1 alice 120.50 alice@shop.com US
1 2 bob -15.00 bob@shop.com US
2 3 None 45.25 not-an-email US
3 4 dana 60.00 dana@shop.com ZZ
4 1 alice 120.50 alice@shop.com US
This one small batch has five distinct problems: a duplicate primary key (order 1 twice), a negative amount, a null required field, a malformed email, and a country code that isn't in your reference list.
Building composable checks¶
Each check returns the failing rows, not just a pass/fail boolean — you need to know which rows to quarantine, not just that something is wrong.
VALID_COUNTRIES = {"US", "CA", "GB", "IN"}
def check_not_null(df: pd.DataFrame, col: str) -> pd.DataFrame:
return df[df[col].isna()]
def check_positive(df: pd.DataFrame, col: str) -> pd.DataFrame:
return df[df[col] <= 0]
def check_valid_email(df: pd.DataFrame, col: str) -> pd.DataFrame:
pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
return df[~df[col].str.match(pattern, na=False)]
def check_valid_enum(df: pd.DataFrame, col: str, allowed: set) -> pd.DataFrame:
return df[~df[col].isin(allowed)]
def check_unique(df: pd.DataFrame, col: str) -> pd.DataFrame:
return df[df.duplicated(subset=col, keep=False)]
failures = {
"null_customer": check_not_null(df, "customer"),
"negative_amount": check_positive(df, "amount"),
"bad_email": check_valid_email(df, "email"),
"bad_country": check_valid_enum(df, "country", VALID_COUNTRIES),
"duplicate_order_id": check_unique(df, "order_id"),
}
for name, rows in failures.items():
print(f"{name}: {len(rows)} row(s) -> {list(rows['order_id'])}")
null_customer: 1 row(s) -> [3]
negative_amount: 1 row(s) -> [2]
bad_email: 1 row(s) -> [3]
bad_country: 1 row(s) -> [4]
duplicate_order_id: 2 row(s) -> [1, 1]
Severity: quarantine vs. warn vs. fail the run¶
Not every violation should stop the pipeline. A good validation layer separates hard failures (data is unsafe to load) from soft warnings (worth flagging, not worth blocking).
HARD_FAILURES = {"null_customer", "duplicate_order_id"}
SOFT_WARNINGS = {"negative_amount", "bad_email", "bad_country"}
bad_ids = set()
for name in HARD_FAILURES:
bad_ids |= set(failures[name]["order_id"])
clean = df[~df["order_id"].isin(bad_ids)].drop_duplicates(subset="order_id")
quarantined = df[df["order_id"].isin(bad_ids)]
print("Clean rows:", len(clean))
print("Quarantined rows:", len(quarantined))
warning_count = sum(len(failures[name]) for name in SOFT_WARNINGS)
print(f"Soft warnings logged (not blocking): {warning_count}")
Order 1's duplicate collapses via drop_duplicates after being confirmed
identical; order 3 (null customer) is quarantined entirely. Orders 2 and 4
stay in the clean set but get a warning logged — a negative amount might be
a legitimate refund, and an unexpected country code might just mean your
reference list is stale, not that the row is corrupt.
A validation report you can alert on¶
def build_quality_report(df: pd.DataFrame, failures: dict) -> pd.DataFrame:
rows = []
for check_name, failed_rows in failures.items():
rows.append({
"check": check_name,
"rows_checked": len(df),
"rows_failed": len(failed_rows),
"failure_rate": round(len(failed_rows) / len(df), 3),
"severity": "hard" if check_name in HARD_FAILURES else "soft",
})
return pd.DataFrame(rows)
report = build_quality_report(df, failures)
print(report)
check rows_checked rows_failed failure_rate severity
0 null_customer 5 1 0.200 hard
1 negative_amount 5 1 0.200 soft
2 bad_email 5 1 0.200 soft
3 bad_country 5 1 0.200 soft
4 duplicate_order_id 5 2 0.400 hard
This report is what you'd write to a data_quality_log table every run —
it turns "the pipeline silently loaded 3 bad rows" into a queryable,
alertable metric with a trend line over time.
Traps¶
- Failing the whole run on any violation. Too strict, and every minor data hiccup pages someone at 2 a.m.; teams disable the checks within a month. Tier severity instead.
- Checking quality after loading to the target. By then it's too late — validate at the Bronze→Silver boundary, before quarantined rows can reach anything a dashboard reads from.
- No quarantine table. Dropping bad rows silently means no one can investigate later — write them somewhere with the failure reason attached.
- Static thresholds that never get revisited. A failure-rate threshold set for a mature pipeline will falsely fire during legitimate growth (e.g., expansion into new countries) — review reference lists and thresholds periodically.
Cheat sheet¶
| Check | pandas idiom |
|---|---|
| Not null | df[col].isna() |
| Range/positivity | boolean comparison, e.g. df[col] <= 0 |
| Pattern match | df[col].str.match(pattern, na=False) |
| Enum membership | ~df[col].isin(allowed_set) |
| Uniqueness | df.duplicated(subset=col, keep=False) |
How It Actually Works¶
Data quality checks are mechanically just queries (aggregations, comparisons) run against the data itself or its metadata, but when and where they execute in the pipeline determines whether they can actually stop bad data or only report on it after the fact.
A schema check compares an incoming batch's inferred or declared types against an expected schema definition before any transform runs — mechanically this is often as cheap as reading a file's Parquet footer or a JSON batch's first N records and diffing field names and types, which is why schema checks can gate a pipeline (fail fast) without paying the cost of a full scan.
A statistical/profiling check (null rate, distinct count, min/max, row count deltas
versus the previous run) requires a full aggregation pass over the batch — COUNT(*),
COUNT(*) FILTER (WHERE col IS NULL), APPROX_COUNT_DISTINCT — and the engine executing it
uses the same scan-and-aggregate machinery as any analytical query, so the check's cost scales
with data volume. Approximate algorithms (HyperLogLog for distinct counts) trade a small,
bounded error for avoiding an exact-but-expensive shuffle-based distinct operation, which
matters once you're validating billions of rows per run.
The mechanical reason validation is usually placed between bronze and silver rather than gating extraction itself is atomicity: you want the raw, possibly-flawed data durably landed (so nothing is lost and you can investigate), and you want the validation gate positioned at a stage boundary where "fail the batch" cleanly means "don't commit this partition to silver" rather than aborting mid-write and leaving a source system's connection in an unclear state. Row-level quarantine (routing failing rows to a dead-letter location instead of failing the whole batch) is implemented as a conditional split during the same transform pass — the engine partitions the DataFrame/RDD by a boolean validity predicate and writes each partition to a different output path, which is why quarantine is nearly free computationally once you're already scanning every row for validation.
Exercise¶
Add a check_referential_integrity(orders_df, customers_df, key) function
that returns every row in orders_df whose customer value does not exist
in a customers_df["name"] column. Run it against the cleaned dataset from
this lesson with a small customers_df of your own, and classify it as a
hard or soft failure with justification.