Skip to content

Data Validation and Schema Contracts

What you’ll learn

  • which data corruptions sklearn catches for you, and which it serves happily: 0.8500 → 0.7125
  • why passing a numpy array instead of a DataFrame disables the only check you were getting
  • a schema contract in about forty lines that catches 6 of 6 corruptions in 6.65 ms
  • how to set range and shift thresholds, and why they fire at +0.75 when accuracy has not moved
  • the honest limit: a validation alarm is not a performance prediction
  • where panderapandera, Great Expectations and pydanticpydantic fit, and what they do not solve

Six corruptions, one fitted pipeline

A StandardScalerStandardScaler plus logistic regression, fitted once on 2,800 rows and scoring 0.8500 on 1,200 clean test rows. Now hand it the same rows, corrupted six ways that all have production analogues.

figure4 corruptions raise. 3 are silent, and the worst costs 0.1375.matplotlib
Horizontal bars of accuracy per corruption. Clean batch 0.8500. Columns reordered, 150 nulls, f02 dropped and f03 renamed all raise ValueError. f00 times 100 gives 0.7125, f00 plus 3 gives 0.8475, and reordering columns as a numpy array gives 0.7933 silently.Horizontal bars of accuracy per corruption. Clean batch 0.8500. Columns reordered, 150 nulls, f02 dropped and f03 renamed all raise ValueError. f00 times 100 gives 0.7125, f00 plus 3 gives 0.8475, and reordering columns as a numpy array gives 0.7933 silently.
sklearn's feature-name check is doing real work: a dropped column, a renamed column and reordered DataFrame columns all raise before any prediction happens. What it cannot see is a value that is wrong rather than misplaced — a unit change costs 0.1375 accuracy and produces no warning at all — or a numpy array, where names do not exist.
CorruptionReal-world causeOutcome
columns reordered (DataFrame)a SELECT *SELECT * after a migrationValueErrorValueError
f02f02 droppedupstream table changedValueErrorValueError
f03f03 renameda “harmless” renameValueErrorValueError
150 nulls in f01f01a failed joinValueErrorValueError
f00f00 × 100cents instead of euros0.7125 (silent)
f00f00 + 3a recalibrated sensor0.8475 (silent)
columns reordered, as numpydf.valuesdf.values in the serving path0.7933 (silent)

Three observations that determine everything below.

sklearn’s DataFrame name check is the cheapest validation you will ever get. It caught four of seven. Keep your serving path on DataFrames with real column names for that reason alone.

df.to_numpy()df.to_numpy() throws it away. The same reordering that raised a ValueErrorValueError as a DataFrame silently costs 0.0567 as an array. Every .values.values or .to_numpy().to_numpy() between your feature code and your model is a place where column order becomes a convention rather than a contract.

Nothing in sklearn checks values. A column in the wrong units is the right shape, the right dtype and the right name. Only a comparison against what training looked like can catch it.

A schema contract in forty lines

Fit the contract on the training frame, then check every batch against it. Everything below is pandaspandas and nothing else.

schema.py
def fit_schema(df, quantile=0.001):
    """Everything about the training frame a serving batch should still satisfy."""
    return {
        "columns": list(df.columns),
        "dtypes": {c: str(df[c].dtype) for c in df.columns},
        "lower": {c: float(df[c].quantile(quantile)) for c in df.columns},
        "upper": {c: float(df[c].quantile(1 - quantile)) for c in df.columns},
        "mean": {c: float(df[c].mean()) for c in df.columns},
        "std": {c: float(df[c].std()) for c in df.columns},
        "null_rate": {c: float(df[c].isna().mean()) for c in df.columns},
        "rows_fitted": len(df),
    }
 
 
def validate(df, schema, max_out_of_range=0.01, max_shift=0.5,
             max_null_increase=0.01):
    """One message per violated expectation. Empty list means the batch is usable."""
    problems = []
    if list(df.columns) != schema["columns"]:
        missing = [c for c in schema["columns"] if c not in df.columns]
        extra = [c for c in df.columns if c not in schema["columns"]]
        if missing:
            problems.append(f"missing columns: {missing}")
        if extra:
            problems.append(f"unexpected columns: {extra}")
        if not missing and not extra:
            problems.append("columns in a different ORDER")
 
    for c in schema["columns"]:
        if c not in df.columns:
            continue
        if str(df[c].dtype) != schema["dtypes"][c]:
            problems.append(f"{c}: dtype {df[c].dtype} != {schema['dtypes'][c]}")
        nulls = float(df[c].isna().mean())
        if nulls > schema["null_rate"][c] + max_null_increase:
            problems.append(f"{c}: null rate {nulls:.4f} "
                            f"(fitted {schema['null_rate'][c]:.4f})")
        col = df[c].dropna()
        if len(col) == 0:
            continue
        out = float(((col < schema["lower"][c]) | (col > schema["upper"][c])).mean())
        if out > max_out_of_range:
            problems.append(f"{c}: {out:.2%} of values outside the fitted range")
        shift = abs(col.mean() - schema["mean"][c]) / max(schema["std"][c], 1e-12)
        if shift > max_shift:
            problems.append(f"{c}: mean shifted {shift:.2f} sd")
    return problems
schema.py
def fit_schema(df, quantile=0.001):
    """Everything about the training frame a serving batch should still satisfy."""
    return {
        "columns": list(df.columns),
        "dtypes": {c: str(df[c].dtype) for c in df.columns},
        "lower": {c: float(df[c].quantile(quantile)) for c in df.columns},
        "upper": {c: float(df[c].quantile(1 - quantile)) for c in df.columns},
        "mean": {c: float(df[c].mean()) for c in df.columns},
        "std": {c: float(df[c].std()) for c in df.columns},
        "null_rate": {c: float(df[c].isna().mean()) for c in df.columns},
        "rows_fitted": len(df),
    }
 
 
def validate(df, schema, max_out_of_range=0.01, max_shift=0.5,
             max_null_increase=0.01):
    """One message per violated expectation. Empty list means the batch is usable."""
    problems = []
    if list(df.columns) != schema["columns"]:
        missing = [c for c in schema["columns"] if c not in df.columns]
        extra = [c for c in df.columns if c not in schema["columns"]]
        if missing:
            problems.append(f"missing columns: {missing}")
        if extra:
            problems.append(f"unexpected columns: {extra}")
        if not missing and not extra:
            problems.append("columns in a different ORDER")
 
    for c in schema["columns"]:
        if c not in df.columns:
            continue
        if str(df[c].dtype) != schema["dtypes"][c]:
            problems.append(f"{c}: dtype {df[c].dtype} != {schema['dtypes'][c]}")
        nulls = float(df[c].isna().mean())
        if nulls > schema["null_rate"][c] + max_null_increase:
            problems.append(f"{c}: null rate {nulls:.4f} "
                            f"(fitted {schema['null_rate'][c]:.4f})")
        col = df[c].dropna()
        if len(col) == 0:
            continue
        out = float(((col < schema["lower"][c]) | (col > schema["upper"][c])).mean())
        if out > max_out_of_range:
            problems.append(f"{c}: {out:.2%} of values outside the fitted range")
        shift = abs(col.mean() - schema["mean"][c]) / max(schema["std"][c], 1e-12)
        if shift > max_shift:
            problems.append(f"{c}: mean shifted {shift:.2f} sd")
    return problems
figure6 of 6 corruptions caught, in 6.65 ms per 1,200-row batchmatplotlib
A grid of six corruptions against five checks. Columns reordered, f02 dropped and f03 renamed are caught by the column-names check; 150 nulls by the null-rate check; f00 times 100 and f00 plus 3 by both the range and mean-shift checks.A grid of six corruptions against five checks. Columns reordered, f02 dropped and f03 renamed are caught by the column-names check; 150 nulls by the null-rate check; f00 times 100 and f00 plus 3 by both the range and mean-shift checks.
Each corruption trips a different check, which is the argument for having all five rather than the one that seems most likely. The two silent value corruptions — the ones sklearn cannot see — are caught by the range and shift checks, and those are precisely the two checks that no library gives you for free.
CorruptionWhich check caught it
columns reorderedcolumn names & order
f02f02 droppedcolumn names & order
f03f03 renamedcolumn names & order (missing and unexpected)
150 nulls in f01f01null rate
f00f00 × 100value range and mean shift
f00f00 + 3value range and mean shift

6.65 ms for 1,200 rows across 25 columns. On a per-request path that is too slow to run on every call; the pattern that works is to validate per batch and, for single-row requests, keep only the cheap checks — names, dtypes, and per-field ranges, which is exactly what a pydanticpydantic model gives you.

Choosing thresholds

The two value checks need numbers, and the numbers determine whether you get useful alarms or noise.

figureThe alarm fires at +0.75, and this shift never costs more than 0.0033matplotlib
Left: fraction of values outside the fitted range and mean shift in standard deviations, both rising with an added shift; the range check crosses its 1% threshold at +0.75. Right: accuracy against the same shift, moving only between 0.8467 and 0.8508.Left: fraction of values outside the fitted range and mean shift in standard deviations, both rising with an added shift; the range check crosses its 1% threshold at +0.75. Right: accuracy against the same shift, moving only between 0.8467 and 0.8508.
The range check crosses 1% out-of-range at a shift of +0.75, and the mean-shift check crosses 0.5 sd at the same point. Meanwhile accuracy wanders between 0.8467 and 0.8508 — no meaningful degradation at all, even at +4.0. The alarms are correct and the model is fine, which is the situation you must design your on-call process around.
CheckThreshold usedWhy
max_out_of_rangemax_out_of_range1% of values outside the fitted 0.1–99.9 percentileWith clean data this is ≈0.2% by construction, so 1% is a real signal
max_shiftmax_shift0.5 sd of the training columnBelow this, batch-to-batch sampling noise dominates
max_null_increasemax_null_increase+1 percentage pointNull rates are usually stable; a jump means a join broke

And the finding that matters most on this page:

A validation alarm is not a performance prediction. The shift of +0.75 that trips both checks costs essentially nothing (0.8508 against a clean 0.8500), and even +4.0 costs 0.0025. This is the same result as input drift monitoring in Phase 08, where PSI reached 2.0847 with accuracy at 0.9995.

The correct reading is not “these checks are useless”. It is:

  • Data validation answers “is this batch like the training data?” — a question about the input, answerable immediately, with no labels.
  • Model monitoring answers “is the model still right?” — a question about the output, answerable only when labels arrive.
  • The first is a precondition, not a proxy. Run it because a batch that violates the contract may be catastrophic (0.7125) or harmless (0.8475) and you cannot tell which from the model’s output, which will look perfectly normal either way.

Practical policy: hard-fail on schema, dtype and null violations, because those are almost always genuine bugs; warn and investigate on range and shift, because those are often legitimate change in the world.

See it move

The uncomfortable part of this page is that “alarm fired” and “model broke” are close to independent. The sketch makes that a two-by-two you can watch fill up: it cycles through the seven batches measured above, shows which gates catch each one, and plots the resulting accuracy against the alarm state.

sketch Alarms and accuracy are close to independent p5.js
Seven corrupted batches cycled one at a time. For each, the four gates light up or stay dark, the resulting accuracy is drawn against the clean baseline of 0.85, and the case is placed into a two-by-two of caught versus harmful. The dangerous quadrant is silent and harmful; the noisy one is caught and harmless.

The two off-diagonal quadrants are the whole design problem. f00 + 3f00 + 3 fires two gates and costs 0.0025, so a team that pages on every alarm learns to ignore them. The numpy reordering fires nothing and costs 0.0567, so a team that trusts silence ships it. Neither is fixed by tightening a threshold — the first needs a severity policy, and the second needs a contract that survives leaving pandas.

Where the libraries fit

diagram Diagram mermaid
ToolWhat it gives youWhat it still will not do
sklearn feature namesFree, automatic column-name and order checking on DataFramesNothing about values; nothing for numpy input
pydanticPer-request field types and bounds, fast, great error messagesStatistics across a batch; distribution shift
panderaDeclarative DataFrame schemas, including statistical checksDeciding your thresholds for you
Great ExpectationsA large check library, data docs, profiling to seed expectationsBeing lightweight; it is a system, not a function
the forty lines aboveAll six catches, zero dependencies, one JSON fileA UI, or anyone else maintaining it

The choice matters much less than the fields you check. A schema that omits value ranges will pass the unit-change batch no matter which library wrote it.

Pitfalls

PitfallWhy it bitesWhat to do
to_numpy()to_numpy() in the serving pathDisables the name check; reordering cost 0.0567 silentlyKeep DataFrames end to end
No value-range checksA unit change cost 0.1375 with no errorFit percentile bounds per column
Trusting a NaN-tolerant modelBoosting served 150 nulls without complaintCheck null rates explicitly
Treating a validation alarm as a performance alarm+0.75 fired both checks and cost 0.0000Hard-fail on schema, investigate on statistics
Thresholds copied from a blogYour columns have your own varianceFit them from training data, then tighten with experience
Validating only at training timeThe corruption happens later, in servingSame contract, both sides, same code
A schema not versioned with the modelSilently checks the wrong expectationsSerialise them together
Logging the prediction but not the validation resultYou cannot join the two afterwardsLog both, with the same request id

Recap

  • Of seven corrupted batches, 4 raised a ValueErrorValueError and 3 were served silently; the worst silent one cost 0.1375 accuracy (0.8500 → 0.7125).
  • Reordering columns raised as a DataFrame and cost 0.0567 silently as a numpy array.
  • A 40-line schema contract caught 6 of 6 corruptions in 6.65 ms per 1,200-row batch.
  • Each corruption tripped a different check, which is why you need all five.
  • The range and shift checks fired at a shift of +0.75, where accuracy was 0.8508 against a clean 0.8500 — validation is a precondition, not a performance prediction.
  • Hard-fail on schema, dtype and nulls; warn and investigate on range and shift.
quizCheck yourself
  1. Your serving code does model.predict(df.to_numpy()). What have you given up?

    Show answer

    B — sklearn's feature-name check — the same column reordering that raises a ValueError for a DataFrame silently cost 0.0567 accuracy as an array — The name check caught four of seven corruptions for free. Converting to numpy makes column order a convention that no code enforces, and a reordering then produces confident, wrong predictions.

  2. A column arrives in cents instead of euros. Which check catches it?

    Show answer

    B — A value-range check against the training percentiles — 97% of values fell outside them, while names, dtypes and null rates were all perfect — The batch is the right shape in every structural sense, which is why it costs 0.1375 accuracy in silence. Range and shift checks are the two that no library gives you by default and the two that catch value corruption.

  3. Your range check fires on a batch and accuracy is unchanged. Was the check wrong?

    Show answer

    B — No: it answered the question it was asked — 'is this batch like training data?' — and a shift of +0.75 tripped it while accuracy went from 0.8500 to 0.8508 — Input validation and performance monitoring are different questions. The same +0.75 shift could have been a unit change costing 0.1375 — and from the model's output alone the two look identical, which is exactly why the input check exists.

  4. Which violations should hard-fail rather than warn?

    Show answer

    B — Schema, dtype and null-rate violations — those are almost always upstream bugs; range and shift are often legitimate change and deserve investigation instead — A missing or renamed column, a changed dtype or a jump in null rate means something broke in the pipeline, and predicting anyway produces garbage with a normal-looking latency. A distribution shift may just be Tuesday.

  5. Where should the fitted schema live?

    Show answer

    B — Serialised beside the model and versioned with it — it is a fitted artefact of the training data, exactly like the scaler — Percentiles, means and null rates come from the training data. A schema fitted on different data than the model checks the wrong expectations, and recomputing it from the serving batch would validate the batch against itself.

🧪 Try It Yourself

Exercise 1 – Corrupt a batch six ways

Exercise 2 – Fit the contract

Exercise 3 – Catch all six

Exercise 4 – Find where the alarm starts

Exercise 5 – Serialise the contract with the model

Next

Feature Stores and Training-Serving Skew — the contract above assumes the serving batch is trying to look like training data. The next page is about the case where two different code paths compute the same feature, and quietly disagree.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did