Machine learning systems inherit the quality of the data that flows through them. A model trained on corrupted features, a feature store populated with stale records, or a serving pipeline fed malformed input will produce confident and wrong predictions. Unlike a software bug that throws an exception, a data quality defect often passes silently through every layer of a system until it surfaces as degraded business metrics weeks later. Data validation and testing exist to make these defects loud, early, and cheap to fix.
This chapter treats data as a first class artifact that deserves the same engineering discipline applied to source code. We examine the spectrum of validation techniques, from rigid schema enforcement to probabilistic statistical checks, run five open source validation libraries (Pandera, Patito, Pointblank, Validoopsie, and Dataframely) against one shared defective batch, and discuss how validation behaves differently in batch and streaming production pipelines. Where the topic warrants precision, we state the underlying mathematics: the drift statistics in particular have exact definitions and properties that determine when each is appropriate.
What this chapter covers
A layered model of data testing. Schema validation pins down structure, constraint checks encode domain logic, statistical validation guards distributions, and data unit tests verify the transformation code itself. A hands on tooling section then expresses one contract in five libraries, executes each against the same batch of 1,000 records with seven planted defects, and compares what each one catches, isolates, and reports. We then place these checks in a production pipeline, formalize the drift metrics that statistical validation relies on, and close with a pragmatic strategy for growing a validation suite without drowning in maintenance.
62.1 Why Data Validation Matters
62.1.1 The Silent Failure Mode
Traditional software fails loudly. A null pointer dereference crashes, a type mismatch refuses to compile, and a failed assertion halts execution. Data pipelines fail quietly. A column that should range from zero to one hundred suddenly contains values in the millions because an upstream service changed its units from dollars to cents. The pipeline does not crash. It computes averages, trains models, and serves predictions, all built on a silent defect.
The cost of a data defect grows with the distance it travels before detection. Catching a malformed record at ingestion costs a retry. Catching it after it has trained a production model costs a retraining cycle, a redeployment, and possibly an incident review. Validation pushes detection as close to the source as possible.
62.1.2 Data Quality Dimensions
Practitioners commonly decompose data quality into several dimensions. Completeness asks whether required values are present. Validity asks whether values conform to their expected type and format. Consistency asks whether related values agree with each other across tables or time. Accuracy asks whether values reflect reality, which is the hardest dimension to test automatically. Timeliness asks whether data arrived within its expected window. Uniqueness asks whether records that should be distinct are in fact distinct.
A mature validation strategy maps each dimension to concrete, automatable checks. Completeness becomes a null count threshold. Validity becomes a type and regex check. Uniqueness becomes a primary key duplication check. Accuracy usually requires a trusted reference source or human review, so teams often approximate it with plausibility bounds.
It helps to see these dimensions as predicates over a dataset. Let \(D\) be a dataset, viewed as a multiset of records drawn from a record space \(\mathcal{R}\). A check is a function \(c : \mathcal{R}^{*} \to \{0, 1\}\) that returns \(1\) when the dataset satisfies the property and \(0\) otherwise. Schema and constraint checks are deterministic predicates: they evaluate the same way every time on the same input. Statistical checks are distributional predicates: they compare a summary of \(D\) against a reference and pass only when the discrepancy stays within a tolerance. This distinction, deterministic versus distributional, is the organizing axis of the chapter, because it dictates how a check is written, where it can run, and how its failures should be interpreted.
62.2 Schema Validation
62.2.1 What a Schema Captures
A schema is a contract that describes the structure of a dataset: the set of columns, their names, their data types, their nullability, and sometimes their permissible value ranges. Schema validation verifies that an incoming dataset honors this contract before any downstream code touches it.
Schema validation is the cheapest and highest leverage form of data testing because it catches an entire class of structural defects with a single declarative specification. When an upstream team renames a column, drops a field, or changes an integer to a string, a schema check fails immediately rather than letting the change propagate.
62.2.2 Explicit Schemas with Pandera
Pandera lets you express a schema as code over pandas, Polars, PySpark, and other dataframe engines, with one import path per engine (pandera.pandas, pandera.polars, and so on). A DataFrameSchema declares columns with types and constraints, and validation either returns the validated frame or raises a SchemaError describing exactly which rows and checks failed. Section 6 executes this style of schema, along with four alternative libraries, on a live batch.
The lazy=True flag is important in practice. Without it, validation stops at the first failure. With it, Pandera collects every failing check across the frame and reports them together, which dramatically shortens the debugging loop when an unfamiliar dataset arrives.
62.2.3 Class Based Schemas and Type Integration
Pandera also offers a class based API using DataFrameModel, which reads like a dataclass and integrates with static type checkers. This style keeps the schema definition close to the rest of your typed code and supports inheritance for shared field definitions.
The strict = True configuration is a deliberate choice. It rejects any column not declared in the schema, which protects against silent additions of unexpected fields. The opposite default, permissive validation, is more forgiving but lets schema drift accumulate unnoticed.
62.2.4 Serialized Schemas for Cross Language Pipelines
When a pipeline spans multiple languages or services, an in code Python schema does not travel well. Serialized schema formats such as JSON Schema, Avro, and Protobuf describe structure in a language neutral way. A producer written in Java and a consumer written in Python can share one Avro schema and enforce it at the boundary. Schema registries, common in Kafka deployments, store these definitions centrally and enforce compatibility rules so that a producer cannot publish a breaking change without explicit approval.
62.3 Constraint Checks
62.3.1 Beyond Structure
Schema validation confirms that a column is an integer. Constraint checks confirm that the integer makes sense. A column may be a perfectly valid float and still be wrong if it represents an age of negative five or a probability of one hundred. Constraint checks encode domain knowledge that no type system can express.
Common constraint categories include range checks (a value falls within known bounds), set membership checks (a category belongs to an allowed list), uniqueness checks (a key does not repeat), referential checks (a foreign key exists in a parent table), and cross column checks (a discount price does not exceed a list price).
62.3.2 Cross Field and Cross Row Constraints
The most valuable constraints often span multiple fields. A record where end_date precedes start_date is structurally valid but logically impossible. A row where total does not equal the sum of its line items signals a computation bug upstream. These relationships are invisible to per column validation and require checks that reason about the whole record.
schema = pa.DataFrameSchema( columns={"start_date": Column(pa.DateTime),"end_date": Column(pa.DateTime), }, checks=Check(lambda df: df["end_date"] >= df["start_date"], error="end_date must not precede start_date", ),)
62.3.3 Choosing Hard versus Soft Constraints
Not every constraint violation should halt a pipeline. A hard constraint, such as a null primary key, justifies rejecting the batch outright because downstream joins will break. A soft constraint, such as a slightly elevated null rate in an optional field, may warrant a warning and a logged metric rather than a full stop. Encoding this distinction explicitly prevents two failure modes: a brittle pipeline that halts on trivial issues, and a permissive pipeline that ignores real corruption. Most validation tools support both raising errors and emitting warnings, and the choice should reflect the genuine downstream impact of each rule.
62.4 Statistical Validation
62.4.1 From Deterministic Rules to Distributions
Schema and constraint checks are deterministic: a value either satisfies the rule or it does not. Many real defects are statistical. The data is individually valid but collectively wrong. A feature whose mean shifts by three standard deviations, a categorical column whose distribution suddenly skews toward one value, or a join that quietly drops half its rows all produce data that passes every per row check while being deeply broken.
Statistical validation compares the shape of incoming data against an expected profile, usually derived from historical reference data. Rather than asking “is this value valid,” it asks “does this dataset look like the data we expect.”
62.4.2 Distributional Checks and Drift
The core technique is to summarize a column with statistics, the mean, standard deviation, quantiles, null fraction, and cardinality, then compare those summaries against a baseline. When the deviation exceeds a threshold, the check fails or raises an alert. Drift is the phenomenon being detected: the distribution of a column in the current window has moved away from the distribution it had in a trusted reference window. Several standard metrics quantify how far it has moved, and they differ in what they assume about the data and in how they weight discrepancies.
Kolmogorov Smirnov statistic. For a continuous column, the two sample Kolmogorov Smirnov (KS) statistic measures the largest gap between two empirical cumulative distribution functions. If \(F_r\) is the empirical CDF of the reference sample and \(F_c\) that of the current sample, then
The statistic lies in \([0, 1]\), is zero only when the two empirical CDFs coincide, and is invariant to any monotone reparameterization of \(x\) because it depends only on ranks. Under the null hypothesis that both samples come from the same continuous distribution, the rescaled statistic has a known limiting distribution, which yields a \(p\) value. The KS test is sensitive to shifts in location and scale but comparatively insensitive to differences in the tails, since the supremum of the CDF gap is usually attained near the center of the distribution.
Chi squared test. For a categorical column with \(k\) categories, partition both samples into category counts. Let \(O_i\) be the observed count of category \(i\) in the current sample and \(E_i\) the count expected if the current sample followed the reference proportions. The statistic
follows, under the null hypothesis of no change, an approximate chi squared distribution with \(k - 1\) degrees of freedom. The approximation degrades when expected counts are small, a common rule of thumb requires \(E_i \geq 5\) for every category, so rare categories should be merged before applying the test.
Population stability index. The population stability index (PSI) is the workhorse of production drift monitoring because it produces a single, threshold friendly number. Bin the column into \(B\) buckets (for a continuous column, fixed quantile edges from the reference; for a categorical column, one bucket per category). Let \(p_i\) be the fraction of reference records in bucket \(i\) and \(q_i\) the fraction of current records in the same bucket. Then
PSI is symmetric in its two arguments and non negative, reaching zero only when \(q_i = p_i\) for every bucket. It is in fact the symmetrized relative entropy between the two binned distributions, sometimes written \(\mathrm{PSI} = D_{\mathrm{KL}}(q \,\|\, p) + D_{\mathrm{KL}}(p \,\|\, q)\), where \(D_{\mathrm{KL}}\) is the Kullback Leibler divergence. A widely used convention treats \(\mathrm{PSI} < 0.1\) as no material shift, \(0.1 \leq \mathrm{PSI} < 0.25\) as a moderate shift worth investigating, and \(\mathrm{PSI} \geq 0.25\) as a significant shift. Empty buckets force a division by zero, so implementations add a small floor \(\epsilon\) to every \(p_i\) and \(q_i\), which is why bucket counts and the choice of \(\epsilon\) must be fixed and recorded for the metric to be reproducible.
Jensen Shannon divergence. When you want a bounded, smooth distance between two probability distributions \(P\) and \(Q\), the Jensen Shannon divergence is a good choice. With \(M = \tfrac{1}{2}(P + Q)\) the average distribution,
\[
\mathrm{JSD}(P \,\|\, Q) = \tfrac{1}{2} D_{\mathrm{KL}}(P \,\|\, M) + \tfrac{1}{2} D_{\mathrm{KL}}(Q \,\|\, M).
\]
Unlike KL divergence, JSD is symmetric, is always finite (the averaged \(M\) never assigns zero probability where \(P\) or \(Q\) is positive), and lies in \([0, \ln 2]\) when using natural logarithms or \([0, 1]\) when using base two logarithms. Its square root is a true metric, the Jensen Shannon distance, which makes it convenient when drift scores are compared or thresholded across many columns.
The practical takeaway is that these metrics answer slightly different questions. KS and chi squared come with hypothesis tests and \(p\) values, which tempt teams to treat drift as a binary significance decision; but with large samples even a trivial, harmless shift becomes statistically significant, so a raw \(p\) value is a poor production gate. PSI and JSD instead report an effect size on a fixed scale, which is what a monitoring dashboard actually needs.
from scipy.stats import ks_2sampstat, p_value = ks_2samp(reference["feature"], current["feature"])if p_value <0.01:raise DataDriftError(f"feature distribution shifted, KS p={p_value:.4f}")
62.4.3 A Worked PSI Calculation
Suppose a feature is bucketed into four bins. The reference window and the current window distribute their mass across those bins as follows.
Bucket
Reference \(p_i\)
Current \(q_i\)
\(q_i - p_i\)
\(\ln(q_i / p_i)\)
Contribution
1
0.40
0.25
-0.15
-0.470
0.0705
2
0.30
0.30
0.00
0.000
0.0000
3
0.20
0.25
0.05
0.223
0.0112
4
0.10
0.20
0.10
0.693
0.0693
Summing the contributions gives \(\mathrm{PSI} \approx 0.151\). By the conventional bands this falls in the moderate range, \(0.1 \leq \mathrm{PSI} < 0.25\), so the column warrants investigation but does not on its own justify halting the pipeline. Notice that bucket 2 contributes exactly zero because its mass is unchanged, while bucket 4 contributes heavily despite a modest absolute change of \(0.10\), because the log ratio amplifies a doubling of probability. This asymmetric weighting, large relative changes in small buckets dominate the score, is the defining behavior of PSI and the reason rare buckets must be sized carefully.
62.4.4 The Threshold Problem
Statistical validation introduces a tuning challenge absent from deterministic checks. Set thresholds too tight and the pipeline alerts on normal seasonal variation, training the team to ignore alarms. Set them too loose and genuine drift slips through. There is no universally correct threshold. Effective teams derive thresholds empirically by replaying historical data and measuring how often each candidate threshold would have fired, then tuning so that alerts correlate with real incidents rather than noise. Statistical checks should be treated as monitoring signals that often warrant investigation rather than hard gates that block deployment.
62.4.5 Validating Against Reference Data
A powerful pattern compares a derived dataset against an independent source of truth. If a feature pipeline computes daily active users, an independent analytics warehouse should report a closely matching count. Reconciliation checks of this kind catch logic bugs that no internal consistency check could find, because they compare two independent computations of the same quantity.
62.5 Data Unit Tests
62.5.1 Treating Data Logic Like Code
The transformations that produce data are code, and code deserves unit tests. A data unit test runs a transformation against a small, fixed input and asserts an exact expected output. Unlike validation, which runs against live production data, a data unit test runs in continuous integration against synthetic fixtures and verifies that the transformation logic itself is correct.
def test_revenue_excludes_refunds(): input_df = pd.DataFrame({"order_id": [1, 2, 3],"amount": [100, 50, 30],"status": ["paid", "refunded", "paid"], }) result = compute_revenue(input_df)assert result ==130# refunded order excluded
This test pins the business rule that refunds do not count toward revenue. If a future change accidentally includes refunds, the test fails in continuous integration before the bad logic ever reaches production data.
62.5.2 Fixtures, Edge Cases, and Property Based Testing
Good data unit tests deliberately include edge cases: empty inputs, single row inputs, nulls in every nullable field, duplicate keys, and boundary values. Property based testing libraries such as Hypothesis generate many randomized inputs and check that invariants hold across all of them, which surfaces edge cases a human would not think to enumerate.
A useful property to assert is that a transformation never introduces nulls into a non nullable output column, or that an aggregation never produces a count larger than its input row count. These structural invariants hold regardless of the specific input values. Several validation libraries can generate such inputs directly from the schema, so fixtures stay in sync with the contract: Patito’s Model.examples fills in valid defaults around the columns a test specifies, and Dataframely’s Schema.sample draws extreme but valid frames that respect every rule, both demonstrated in Section 6.
62.5.3 The dbt Testing Model
In analytics engineering, dbt has popularized declarative data tests defined in YAML alongside model definitions. Built in generic tests cover the most common assertions, and custom tests are expressed as SQL queries that should return zero rows, where any returned row represents a failure.
This model blurs the line between unit testing and production validation. The same assertions run during development against sample data and in production against the full warehouse, which is one of dbt’s most practical strengths.
62.6 Tooling: Five Open Source Validators, Side by Side
The ideas of Sections 2 through 5 pay off only once they are wired into code that runs on every batch. The Python ecosystem now offers several mature, MIT licensed libraries for this, and they make genuinely different design choices about how a contract is written, what happens when it is broken, and what evidence is left behind. This section runs five of them, Pandera, Patito, Pointblank, Validoopsie, and Dataframely, against one contract and one defective batch, so that their differences show up as differences in output rather than in feature lists. All five accept Polars frames. Patito and Dataframely are built directly on Polars; Pandera has a dedicated Polars backend alongside pandas, PySpark, and Ibis; Pointblank and Validoopsie reach Polars, pandas, and other engines through the Narwhals dataframe compatibility layer.
62.6.1 A Shared Contract and a Defective Batch
Section 1.2 described a check as a predicate over a dataset. To compare libraries we need one more piece of notation. Let the batch be \(D = (r_1, \dots, r_n)\) and the contract a set of \(K\) checks, and write \(F_k \subseteq \{1, \dots, n\}\) for the set of rows that violate check \(k\). Most checks are row local: whether row \(j\) violates “age lies in \([0, 120]\)” depends on \(r_j\) alone. Uniqueness is not. Row \(j\) violates “user_id is unique” exactly when some other row \(i \neq j\) carries the same key, so \(F_{\text{uniq}}\) is either empty or has at least two elements, and it can only be computed by looking at the whole column. This distinction explains several of the differences between libraries below.
A quarantine policy (Section 7.2) diverts the union of the failure sets and promotes the rest,
with equality exactly when no row breaks two rules at once. A soft check with tolerance \(\tau_k\) passes when its failure fraction stays at or below that tolerance, \(|F_k| / n \le \tau_k\). This is how Pointblank and Validoopsie encode the hard versus soft distinction of Section 3.3: a hard check is simply one with \(\tau_k = 0\).
The batch is 1,000 synthetic user records. After generating a clean frame, the cell injects seven defects at known rows, one per rule: a duplicated key, an impossible age, a missing age, a malformed email, a country outside the allowed set, an activity date that precedes signup, and a negative spend.
Code
from datetime import date, timedeltaimport numpy as npimport polars as plrng = np.random.default_rng(55)n =1_000EMAIL_RE =r"^[\w.+-]+@[\w-]+\.[\w.]+$"ALLOWED = ["US", "CA", "GB", "DE"]signup = [date(2024, 1, 1) + timedelta(days=int(d)) for d in rng.integers(0, 365, n)]clean = pl.DataFrame({"user_id": np.arange(1, n +1, dtype=np.int64),"age": rng.integers(18, 90, n, dtype=np.int64),"email": [f"user{i}@example.com"for i inrange(1, n +1)],"country": rng.choice(ALLOWED, n).tolist(),"signup_date": signup,"last_active": [s + timedelta(days=int(d)) for s, d inzip(signup, rng.integers(0, 400, n))],"spend": np.round(rng.gamma(shape=2.0, scale=40.0, size=n), 2),})# Inject one defect per rule at known row positions.row = pl.int_range(pl.len())batch = clean.with_columns( user_id=pl.when(row ==10).then(pl.lit(10, pl.Int64)).otherwise("user_id"), age=pl.when(row ==100).then(250).when(row ==200).then(None).otherwise("age"), email=pl.when(row ==300).then(pl.lit("bob_at_example.com")).otherwise("email"), country=pl.when(row ==400).then(pl.lit("FR")).otherwise("country"), last_active=pl.when(row ==500) .then(pl.col("signup_date") - pl.duration(days=30)) .otherwise("last_active"), spend=pl.when(row ==600).then(-12.5).otherwise("spend"),)batch.slice(8, 4)
shape: (4, 7)
user_id
age
email
country
signup_date
last_active
spend
i64
i64
str
str
date
date
f64
9
56
"user9@example.com"
"GB"
2024-08-02
2024-08-19
26.05
10
44
"user10@example.com"
"DE"
2024-06-28
2024-08-27
134.22
10
57
"user11@example.com"
"DE"
2024-05-03
2025-05-13
177.05
12
70
"user12@example.com"
"CA"
2024-10-01
2025-05-30
80.77
Rows 9 and 10 now share user_id 10, and the other six defects sit at rows 100 through 600. Before reaching for any library, it is worth writing the contract in the plainest form available: one Polars expression per rule that evaluates to true on a violation. This reference implementation is the ground truth against which the five libraries are checked, and because it uses nothing but expressions it ports directly to Julia and Rust.
# Illustrative: the same seven rules over DataFrames.jl.# `batch` exported from Python with batch.write_parquet("users_batch.parquet").usingDataFrames, Dates, Parquet2const EMAIL_RE =r"^[\w.+-]+@[\w-]+\.[\w.]+$"const ALLOWED = Set(["US", "CA", "GB", "DE"])batch =DataFrame(Parquet2.Dataset("users_batch.parquet"); copycols =false)functionviolations(df::AbstractDataFrame)DataFrame("user_id unique"=>nonunique(df, :user_id; keep =:noduplicates),"age not null"=>ismissing.(df.age),"age in [0, 120]"=>coalesce.((df.age .<0) .| (df.age .>120), false),"email format"=> .!occursin.(Ref(EMAIL_RE), df.email),"country allowed"=> .!in.(df.country, Ref(ALLOWED)),"active after signup"=> df.last_active .< df.signup_date,"spend >= 0"=> df.spend .<0, )endv =violations(batch)truth = [String(name) =>sum(col) for (name, col) inpairs(eachcol(v))]quarantine =findall(any.(eachrow(v))) .-1# zero based, to match Polarsprintln(truth)println("quarantine rows: ", quarantine)
// Illustrative: the same seven rules as Polars expressions in Rust.// Cargo.toml: polars = { version = "0.51", features = ["lazy", "parquet", "regex",// "strings", "is_unique", "dtype-date"] }usepolars::prelude::*;const EMAIL_RE:&str=r"^[\w.+-]+@[\w-]+\.[\w.]+$";const ALLOWED: [&str;4] = ["US","CA","GB","DE"];fn rules() ->Vec<Expr>{let allowed = ALLOWED.iter().fold(lit(false),|acc, c| acc.or(col("country").eq(lit(*c))));vec![ col("user_id").is_duplicated().alias("user_id unique"), col("age").is_null().alias("age not null"), col("age").lt(lit(0)).or(col("age").gt(lit(120))).alias("age in [0, 120]"), col("email").str().contains(lit(EMAIL_RE),true).not().alias("email format"), allowed.not().alias("country allowed"), col("last_active").lt(col("signup_date")).alias("active after signup"), col("spend").lt(lit(0.0)).alias("spend >= 0"), ]}fn main() -> PolarsResult<()>{let batch =LazyFrame::scan_parquet("users_batch.parquet",ScanArgsParquet::default())?;let flags:Vec<Expr>= rules().into_iter().map(|e| e.fill_null(lit(false))).collect();let truth = batch.clone().select(flags.iter().cloned().map(|e| e.sum()).collect::<Vec<_>>()).collect()?;println!("{truth}");let quarantine = batch.with_row_index("row",None).filter(any_horizontal(&flags)?).select([col("row")]).collect()?;println!("quarantine rows: {quarantine}");Ok(())}
Seven rules fail. The duplicated key contributes two rows to \(F_{\text{uniq}}\) and every other rule contributes one, and since no row breaks two rules the quarantine set has \(|Q| = \sum_k |F_k| = 8\) rows. A correct validator must find all seven violations; a validator that quarantines should isolate these eight rows. Following Section 3.3, the three rules marked hard (a duplicated key breaks joins, a missing age and a time reversal signal a corrupt record) should stop the pipeline, while the four soft rules should be recorded without necessarily blocking it.
62.6.2 Pandera: Schemas as Typed Classes
Pandera, introduced in Section 2 on pandas frames, provides a Polars backend under pandera.polars (Bantilan 2020). A DataFrameModel declares one typed attribute per column, Field arguments attach built in checks, and a method decorated with @pa.dataframe_check expresses the cross column rule. Setting strict = True makes an undeclared column a failure in its own right.
With lazy=True, Pandera collects every failure into a single SchemaErrors exception whose failure_cases attribute is itself a Polars frame: one row per failing value, with the check that failed and the row index where it failed. Both members of the duplicated key appear, rows 9 and 10, because uniqueness is a property of the whole column, and the cross column rule reports row 500 with the offending record serialized as its failure case. The eight distinct indices are exactly the reference quarantine set.
The schema also doubles as a type annotation. Decorating a function with @pa.check_types validates its annotated arguments at call time, so a transformation cannot begin on a batch that breaks its input contract. This is the inline, fail fast placement that Section 7.1 recommends after each transformation.
Code
from pandera.typing.polars import DataFrame@pa.check_types(lazy=True)def spend_by_country(users: DataFrame[UserSchema]) -> pl.DataFrame:return users.group_by("country").agg(pl.col("spend").sum().round(2)).sort("country")print(spend_by_country(clean))try: spend_by_country(batch)except SchemaErrors as exc:print(f"rejected before aggregating: {exc.failure_cases.height} failure cases")
shape: (4, 2)
┌─────────┬──────────┐
│ country ┆ spend │
│ --- ┆ --- │
│ str ┆ f64 │
╞═════════╪══════════╡
│ CA ┆ 19816.72 │
│ DE ┆ 20285.11 │
│ GB ┆ 18467.98 │
│ US ┆ 19434.31 │
└─────────┴──────────┘
rejected before aggregating: 8 failure cases
62.6.3 Patito: Pydantic Models for DataFrames
Patito reaches the same contract from the other direction: it is written as a Pydantic model, so the class that validates a whole frame is also an ordinary row model that can validate an API payload or a configuration record. Nullability follows Python typing (an int field rejects nulls, an int | None field accepts them), a Literal type replaces an allowed set, and pt.field stands for the column being defined when a constraint needs an arbitrary Polars expression.
Code
from typing import Literalimport patito as ptfrom patito.exceptions import DataFrameValidationErrorclass User(pt.Model): user_id: int= pt.Field(gt=0, unique=True) age: int= pt.Field(ge=0, le=120) # not Optional, so a null is an error email: str= pt.Field(pattern=EMAIL_RE) country: Literal["US", "CA", "GB", "DE"] signup_date: date last_active: date = pt.Field(constraints=pt.field >= pl.col("signup_date")) spend: float= pt.Field(ge=0)User.validate(clean)try: User.validate(batch)except DataFrameValidationError as exc: patito_errors = exc.errors()print(exc)
7 validation errors for User
age
1 missing value (type=value_error.missingvalues)
user_id
2 rows with duplicated values. (type=value_error.rowvalue)
age
1 row with out of bound values. (type=value_error.rowvalue)
email
1 row with out of bound values. (type=value_error.rowvalue)
country
Rows with invalid values: {'FR'}. (type=value_error.rowvalue)
last_active
1 row does not match custom constraints. (type=value_error.rowvalue)
spend
1 row with out of bound values. (type=value_error.rowvalue)
Patito reports all seven violations in the familiar Pydantic error format, grouped by column. The messages carry counts (“2 rows with duplicated values”) and, for categorical violations, the offending values, but not row positions: Patito answers whether a frame conforms and leaves locating bad rows to the caller.
Its distinctive extra is fixture generation for the data unit tests of Section 5. Model.examples builds a valid frame from only the columns a test cares about and fills every other column with a value that satisfies its type and constraints. It cannot invent strings that match a regular expression, so a pattern constrained column such as email must be supplied explicitly.
Code
fixture = User.examples({"user_id": [1, 2, 3],"email": ["a@x.io", "b@y.io", "c@z.io"],"spend": [0.0, 19.99, 1_000_000.0], # boundary values the test cares about})User.validate(fixture)fixture.select(User.columns)
shape: (3, 7)
user_id
age
email
country
signup_date
last_active
spend
i64
i64
str
enum
date
date
f64
1
60
"a@x.io"
"US"
1970-01-01
1970-01-01
0.0
2
60
"b@y.io"
"US"
1970-01-01
1970-01-01
19.99
3
60
"c@z.io"
"US"
1970-01-01
1970-01-01
1e6
62.6.4 Pointblank: Validation Plans with Severity Thresholds
Pointblank, from Posit, treats validation as a plan: a chain of named steps, each a check over one or more columns, interrogated together and summarized in a report. Its central idea is graded severity. Every step carries Thresholds for warning, error, and critical states, given either as absolute counts of failing rows (integers) or as failure fractions (floats), which is precisely the tolerance \(\tau_k\) of Section 6.1. Below, the three hard rules get critical=1, so a single failing row is critical, while soft rules escalate from warning at one failing row to error at 1 percent and critical at 5 percent. The plan opens with a schema step whose expected column names and types are read from the clean reference frame.
Code
import pointblank as pbHARD_T = pb.Thresholds(critical=1) # one failing row is fatalSOFT_T = pb.Thresholds(warning=1, error=0.01, critical=0.05) # escalate with the ratevalidation = ( pb.Validate(data=batch, tbl_name="users", label="Nightly signup batch", thresholds=SOFT_T) .col_schema_match(schema=pb.Schema(tbl=clean), brief="schema") .rows_distinct(columns_subset=["user_id"], thresholds=HARD_T, brief="user_id unique") .col_vals_not_null(columns="age", thresholds=HARD_T, brief="age not null") .col_vals_between(columns="age", left=0, right=120, na_pass=True, brief="age in [0, 120]") .col_vals_regex(columns="email", pattern=EMAIL_RE, brief="email format") .col_vals_in_set(columns="country", set=ALLOWED, brief="country allowed") .col_vals_ge( columns="last_active", value=pb.col("signup_date"), thresholds=HARD_T, brief="active after signup", ) .col_vals_ge(columns="spend", value=0, brief="spend >= 0") .interrogate())validation.get_tabular_report()
Pointblank Validation
Nightly signup batch
PolarsusersWARNING1ERROR0.01CRITICAL0.05
STEP
COLUMNS
VALUES
TBL
EVAL
UNITS
PASS
FAIL
W
E
C
EXT
#4CA64C
1
col_schema_match()
schema
—
SCHEMA
✓
1
1 1.00
0 0.00
○
○
○
—
#FF3300
2
rows_distinct()
user_id unique
user_id
—
✓
1000
998 1.00
2 0.00
—
—
●
#FF3300
3
col_vals_not_null()
age not null
age
—
✓
1000
999 1.00
1 0.00
—
—
●
#AAAAAA
4
col_vals_between()
age in [0, 120]
age
[0, 120]
✓
1000
999 1.00
1 0.00
●
○
○
#AAAAAA
5
col_vals_regex()
email format
email
^[\w.+-]+@[\w-]+\.[\w.]+$
✓
1000
999 1.00
1 0.00
●
○
○
#AAAAAA
6
col_vals_in_set()
country allowed
country
US, CA, GB, DE
✓
1000
999 1.00
1 0.00
●
○
○
#FF3300
7
col_vals_ge()
active after signup
last_active
signup_date
✓
1000
999 1.00
1 0.00
—
—
●
#AAAAAA
8
col_vals_ge()
spend >= 0
spend
0
✓
1000
999 1.00
1 0.00
●
○
○
2026-10-17 23:20:26 UTC< 1 s2026-10-17 23:20:26 UTC
Step 2(local_thresholds) Step-specific thresholds set with C:1.
Step 3(local_thresholds) Step-specific thresholds set with C:1.
Step 7(local_thresholds) Step-specific thresholds set with C:1.
The report is a self contained HTML table, built with the Great Tables library, listing every step with its pass and fail counts, failure fractions, and threshold states. It is an artifact a data steward or analyst can read without opening Python, the communication role that Data Docs play in Great Expectations (Section 6.9).
Two calls turn the plan into pipeline behavior. assert_below_threshold is the gate: asked about the critical level it fails on exactly the three hard rules, while the four soft violations, each one row in a thousand, sit at warning and do not block. get_sundered_data is the quarantine, splitting the batch into passing and failing partitions.
Code
try: validation.assert_below_threshold(level="critical")exceptAssertionErroras exc:print(exc)passed = validation.get_sundered_data(type="pass")failed = validation.get_sundered_data(type="fail")print(f"pass partition: {passed.height} rows, fail partition: {failed.height} rows")print(f"duplicated user_id rows left in the pass partition: {passed['user_id'].is_duplicated().sum()}")
The following steps exceeded the critical threshold level:
Step 2: Expect entirely distinct rows across `user_id`.
Step 3: Expect that all values in `age` should not be Null.
Step 7: Expect that values in `last_active` should be >= `signup_date`.
pass partition: 994 rows, fail partition: 6 rows
duplicated user_id rows left in the pass partition: 2
Note what the split does with the duplicated key. rows_distinct is a judgment about the table rather than about any single row (the \(F_{\text{uniq}}\) case of Section 6.1), so it does not take part in the row level split and both duplicate rows remain in the pass partition. When keys must be enforced at quarantine time, pair Pointblank’s report with an explicit deduplication step, or with a library that treats key violations as row failures, such as Pandera or Dataframely.
62.6.5 Validoopsie: Impact Levels and Tolerances on Any Backend
Validoopsie is the lightest of the five. It is written against Narwhals, so one validation runs unchanged on pandas, Polars, PyArrow, and the other engines Narwhals supports, and its catalog is organized into themed groups (NullValidation, ValuesValidation, StringValidation, and so on) chained from a single Validate object. Each check takes an impact level and a fractional threshold. Impact sets the consequence when validate() runs: a failed high impact check raises, a medium one is logged as an error, and a low one as a warning. The threshold is the tolerance \(\tau_k\), and a check whose failure fraction stays within it reports success.
For the low impact rules the tolerance should come from the reasoning of Section 4.4 rather than a guess. If a healthy batch fails a soft rule at a benign rate \(p_0\), the failure count is binomial with mean \(n p_0\) and variance \(n p_0 (1 - p_0)\), so a tolerance three standard errors above the benign rate is
With \(p_0 = 0.002\) and \(n = 1000\) this gives \(\tau \approx 0.0062\), about six failing rows per thousand before the check fails. Validoopsie has no built in check that orders two columns, so the date rule is written as a custom check by subclassing BaseValidation. Its __call__ receives a Narwhals frame and returns the failing values grouped with their counts, which keeps the custom rule backend agnostic as well.
The summary shows all three tiers at work. The two low impact checks each see one failing row, a fraction of 0.001 that falls inside \(\tau\), so they report success while still recording the count for monitoring. The two medium checks fail with zero tolerance but are only logged. Validoopsie logs through Loguru, so routing that log to a compact sink at warning level and above makes the tiers visible: three critical lines for the high impact failures, two error lines for the medium ones, and nothing for the low impact checks that stayed within tolerance. Only the high impact failures stop the pipeline:
CRITICAL Failed validation: ColumnUniquePair_user_id - Duplicate entries found: The combination of columns [user_id] contains non-unique values.
CRITICAL Failed validation: ColumnNotBeNull_age - The column 'age' has values that are null.
CRITICAL Failed validation: ColumnNotBefore_last_active - 'last_active' precedes 'signup_date' in some rows.
ERROR Failed validation: ColumnValuesToBeBetween_age - The column 'age' has values that are not between 0 and 120.
ERROR Failed validation: PatternMatch_email - The column 'email' has entries that do not match the pattern '^[\w.+-]+@[\w-]+\.[\w.]+$'.
raised: Failed Validation(s): ['ColumnUniquePair_user_id', 'ColumnNotBeNull_age', 'ColumnNotBefore_last_active']
Because every check is expressed through Narwhals, the same validation runs on a pandas copy of the batch without modification:
{'status': 'Fail',
'threshold_pass': False,
'message': "The column 'spend' has values that are not between 0 and None.",
'failing_items': [-12.5],
'failed_number': 1,
'frame_row_number': 1000,
'threshold': 0.0,
'failed_percentage': 0.001}
62.6.6 Dataframely: Polars Native Schemas, Quarantine, and Sampling
Dataframely, from QuantCo, is Polars native and the most opinionated about types. Columns are non nullable unless declared otherwise, a primary_key flag replaces a separate uniqueness check, and cross column logic is a method decorated with @dy.rule() that returns a Polars expression evaluated per row. Its central operation is filter, which never raises: it returns the rows that satisfy every rule together with a FailureInfo object describing the rest. The quarantine pattern of Section 7.2 is built into the API.
Code
import dataframely as dyclass UserTable(dy.Schema): user_id = dy.Int64(primary_key=True, min=1) age = dy.Int64(min=0, max=120) # non nullable by default email = dy.String(regex=EMAIL_RE) country = dy.String(check=lambda c: c.is_in(ALLOWED)) signup_date = dy.Date() last_active = dy.Date() spend = dy.Float64(min=0)@dy.rule()def active_after_signup(cls) -> pl.Expr:return pl.col("last_active") >= pl.col("signup_date")valid, failure = UserTable.filter(batch)print(f"{valid.height} rows promoted, {failure.invalid().height} rows quarantined")failure.counts()
The counts match the reference implementation rule for rule, including two rows for the primary key, and the eight quarantined rows are exactly the reference quarantine set. FailureInfo can also report which rules fail together (cooccurrence_counts) and be written to Parquet for later inspection. When fail fast behavior is wanted instead, validate raises with a summary of the same counts:
Code
try: UserTable.validate(batch)except dy.exc.ValidationError as exc:print(exc)
7 rules failed validation:
- 'active_after_signup' failed for 1 rows
- 'primary_key' failed for 2 rows
* Column 'age' failed validation for 2 rules:
- 'nullability' failed for 1 rows
- 'max' failed for 1 rows
* Column 'email' failed validation for 1 rules:
- 'regex' failed for 1 rows
* Column 'country' failed validation for 1 rules:
- 'check' failed for 1 rows
* Column 'spend' failed validation for 1 rules:
- 'min' failed for 1 rows
Two further features set Dataframely apart. A validated frame is typed as dy.DataFrame[UserTable], so a function signature can demand a frame that has already passed the schema and a static type checker flags code that passes an unvalidated one. And sample generates synthetic frames that satisfy the entire schema, including the cross column rule and the primary key, which turns the schema into a source of property based test fixtures (Section 5.2). The generator deliberately explores extreme but valid values, very large keys and amounts and dates centuries apart, because those are the inputs that break transformation code. Columns with a custom check cannot be sampled automatically, so country receives explicit values.
Beyond single tables, a dy.Collection groups several schemas and adds rules across them, such as requiring every order to reference an existing user, which brings the referential checks of Section 3.1 into the same declarative framework.
62.6.7 Scorecard: One Contract, Five Verdicts
The cell below gathers what each library reported on the defective batch.
Code
vo_results = [res for key, res in vd.results.items() if key !="Summary"]scorecard = pl.DataFrame({"library": ["reference", "pandera", "patito", "pointblank", "validoopsie", "dataframely"],"rules_flagged": [ truth.height, pandera_failures.select("column", "check").n_unique(),len(patito_errors),sum(count >0for count in validation.n_failed().values()),sum(res["result"].get("failed_number", 0) >0for res in vo_results),len(failure.counts()), ],"rows_isolated": [ quarantine.height, pandera_failures["index"].n_unique(),None, validation.get_sundered_data(type="fail").height,None, failure.invalid().height, ],"default_on_failure": ["none (plain expressions)","raise (all failures with lazy=True)","raise","report; assert by severity level","raise on high impact only","filter splits; validate raises", ],})scorecard
shape: (6, 4)
library
rules_flagged
rows_isolated
default_on_failure
str
i64
i64
str
"reference"
7
8
"none (plain expressions)"
"pandera"
7
8
"raise (all failures with lazy=…
"patito"
7
null
"raise"
"pointblank"
7
6
"report; assert by severity lev…
"validoopsie"
7
null
"raise on high impact only"
"dataframely"
7
8
"filter splits; validate raises"
All five libraries flag the same seven rules as the reference. They differ in what they hand back. Pandera and Dataframely isolate exactly the eight rows of the reference quarantine set. Pointblank’s row level split isolates six, because its uniqueness step judges the table rather than individual rows. Patito and Validoopsie report rule level verdicts and counts rather than row positions, which is enough for a gate and for monitoring but not, on its own, for quarantine. The broader differences are summarized below.
Pandera
Patito
Pointblank
Validoopsie
Dataframely
Contract style
Class or object schema
Pydantic model
Chained validation plan
Chained validation plan
Class schema with rules
Engines
pandas, Polars, PySpark, Ibis, and others
Polars
Polars, pandas, SQL databases via Ibis
Any Narwhals backend
Polars
On failure
Raises; collects all with lazy=True
Raises with all errors
Reports; asserts by severity on request
Raises for high impact only
filter splits; validate raises
Severity model
Error, or warning per check
Error only
Warning, error, critical thresholds
Impact level plus tolerance
Error only
Row level output
Failure cases with row index
Counts and values
Pass and fail partitions
Failing values and counts
Valid frame plus FailureInfo
Human facing report
Failure case frame
Pydantic style summary
HTML report table
Text summary table
Rule count summary
Test data generation
Hypothesis strategies (pandas)
Model.examples
generate_dataset
None built in
Schema.sample
Distinctive extra
check_types decorator
One model for rows and frames
YAML plans, step reports
Small custom check API
Multi table Collection
62.6.8 Choosing Among Them
The choice follows the shape of the codebase and the audience for the results. Reach for Pandera when one validation vocabulary must span several engines (pandas in notebooks, Polars in pipelines, PySpark at scale) or when typed function boundaries matter, since check_types validates at every call. Reach for Patito when Pydantic models are already the lingua franca, for example when the same record shape arrives through an API and leaves through a dataframe, and one class should govern both. Reach for Pointblank when the people who must act on validation results are analysts, stewards, or managers, when graded severity matters more than pass or fail, or when the data lives in a SQL database. Reach for Validoopsie when the goal is a small dependency footprint, backend portability, and tiered severity in a few lines. Reach for Dataframely in Polars native pipelines that need quarantine, typed frames, multi table rules, or schema driven test data.
The libraries also compose. A realistic arrangement for the zones of Section 7.1 places Dataframely or Pandera at the schema and constraint gates inside transformation code, where typed frames and row level quarantine matter, and Pointblank at the statistical and reporting gate, where results must be read and acted on by people. The cost of combining tools is that the contract is written more than once. Keep one authoritative definition, such as the reference expressions of Section 6.1, and test in continuous integration that every library’s translation agrees with it on a fixture batch, exactly as the scorecard does.
62.6.9 Adjacent Tooling
Great Expectations remains the most widely deployed framework in this space. It is built around Expectations, declarative assertions such as expect_column_values_to_not_be_null, grouped into suites, run through checkpoints, and rendered as Data Docs, readable HTML reports that serve as both documentation and test results. It suits organizations where many teams share one catalog of expectations, at the cost of more configuration and a heavier conceptual footprint than the five libraries above. Soda offers a declarative checks language aimed at data reliability monitoring. Deequ, built on Spark, brings constraint verification and data profiling to very large datasets and can suggest constraints automatically (Schelter et al. 2018). Evidently focuses on drift and model monitoring with visual reports, the natural companion to the statistical validation of Section 4. The right combination depends on data scale, the prevailing language stack, and whether the emphasis is on batch validation or continuous monitoring.
62.7 Validating Data in Production Pipelines
62.7.1 Where to Place Validation Gates
Validation in production is about placement as much as technique. The standard pattern places gates at every boundary where data changes ownership: at ingestion, where external data enters the system; after each major transformation, where bugs are introduced; and before serving, where bad data would directly harm users or models. Validating only at the end leaves you knowing that something broke without knowing where. Validating at each boundary localizes failures to a specific stage.
A widely used architecture routes data through quality zones, often named for metals. Raw data lands in a bronze zone with minimal validation. Cleaned and conformed data moves to a silver zone after passing schema and constraint checks. Business level aggregates reach a gold zone only after statistical validation confirms they look reasonable. Each promotion between zones is a validation gate, and a record that fails a gate is diverted rather than silently promoted.
flowchart LR
SRC["External source"] --> G1{"Schema gate"}
G1 -->|"pass"| BRONZE["Bronze zone raw"]
G1 -->|"fail"| Q1["Quarantine"]
BRONZE --> G2{"Constraint gate"}
G2 -->|"pass"| SILVER["Silver zone conformed"]
G2 -->|"fail"| Q2["Quarantine"]
SILVER --> G3{"Statistical gate"}
G3 -->|"pass"| GOLD["Gold zone aggregates"]
G3 -->|"alert"| MON["Monitoring and review"]
GOLD --> SERVE["Serving and models"]
The gates are ordered from cheapest and strictest to most expensive and most forgiving. The schema gate is a hard structural check at the boundary. The constraint gate adds domain logic. The statistical gate, placed last, is usually a monitoring signal rather than a hard stop, which is why its failure edge routes to review rather than to quarantine.
62.7.2 Fail Fast versus Quarantine
When validation fails in production, the pipeline must decide what to do with the offending data. Two strategies dominate. The fail fast strategy halts the pipeline, alerts an operator, and prevents bad data from propagating, which is appropriate when downstream consumers cannot tolerate any corruption. The quarantine strategy diverts failing records to a separate location for later inspection while allowing valid records to proceed, which keeps the pipeline flowing when partial data is better than none.
The right choice depends on the use case. A financial reconciliation pipeline should fail fast because a wrong number is worse than a late number. A recommendation feature pipeline might quarantine a small fraction of malformed events and continue, because serving slightly incomplete recommendations beats serving none. Encoding this policy explicitly, rather than letting it emerge from where exceptions happen to propagate, is a mark of a mature pipeline. The libraries of Section 6 make the choice an API decision: Pandera’s validate, Patito’s validate, and Dataframely’s validate fail fast, while Dataframely’s filter and Pointblank’s get_sundered_data quarantine, returning the passing rows and the failing rows separately.
62.7.3 Batch versus Streaming Validation
Batch pipelines validate a whole dataset at once, which makes statistical checks straightforward because the full distribution is available. Streaming pipelines validate one record or a small window at a time, which constrains the kinds of checks that are feasible. A per record schema check is trivial in a stream. A distributional drift check is not, because no single record reveals a distribution.
Streaming systems address this by maintaining rolling windows and incremental statistics, computing approximate distributions over a recent window and comparing them against a baseline. Per record validation handles structural and constraint checks at the edge, while windowed aggregation handles statistical checks slightly behind the live edge. Records that fail structural checks are typically routed to a dead letter queue, the streaming analog of quarantine.
62.7.4 Validation as Monitoring
In production, validation results are themselves a stream of metrics. The null rate of a column, the number of quarantined records, the drift score against baseline, and the pass rate of each expectation suite should all be emitted to a monitoring system and tracked over time. This transforms validation from a binary gate into an observable signal. A slow upward creep in a null rate, invisible to any single threshold check, becomes obvious on a time series chart and lets teams intervene before the rate crosses a failure boundary.
Effective teams alert on validation metrics with the same rigor they apply to service latency and error rates. A failing expectation suite should page someone, a rising quarantine rate should open a ticket, and a sustained drift signal should trigger an investigation into whether a model needs retraining.
62.7.5 Versioning Expectations with Data
Validation logic evolves as understanding of the data deepens. New constraints get added, thresholds get tuned, and obsolete checks get removed. Because validation suites are code, they belong in version control alongside the pipeline they protect, reviewed through the same pull request process. When a validation rule changes, the change history explains why, which is invaluable during an incident review months later. Treating expectations as versioned artifacts also enables a disciplined workflow: propose a new constraint, test it against historical data to estimate its failure rate, and only then promote it to a blocking gate.
62.8 Building a Validation Strategy
62.8.1 Start at the Boundaries
A team cannot validate everything at once, and attempting to do so produces a brittle thicket of checks that nobody maintains. The pragmatic starting point is to enforce schemas at the boundaries where external data enters the system, because that is where the most defects originate and where a single check protects the most downstream code. Schema enforcement at ingestion delivers the highest return for the least effort.
62.8.2 Layer in Depth Over Time
Once boundary schemas are in place, layer additional validation where incidents reveal it is needed. Each production data incident is a lesson that should become a permanent check, so that the same defect can never recur silently. This incident driven growth keeps the validation suite focused on real risks rather than hypothetical ones, and it ensures that every check earns its maintenance cost by guarding against a failure that actually happened.
62.8.3 Balancing Coverage and Maintenance
Every validation rule is code that must be maintained, and an overzealous suite that fires constantly on benign variation trains a team to ignore it, which is worse than having no validation at all. The goal is not maximal coverage but calibrated coverage, where each check that fires reliably signals a real problem worth a human’s attention. Periodically auditing which checks have fired, which have never fired, and which fire so often they are ignored keeps the suite healthy and trusted.
62.8.4 When to Use Each Layer, and the Pitfalls of Each
The four validation layers are not interchangeable, and a healthy strategy reaches for each where it is strongest.
Reach for schema validation at every boundary where data enters or changes ownership. It is cheap, declarative, and catches the structural defects that cause the loudest downstream failures. Its pitfall is over strictness: a schema that rejects any unexpected column will halt a pipeline when an upstream team adds a harmless field, so reserve strict mode for boundaries where unexpected columns genuinely signal a problem.
Reach for constraint checks when domain logic constrains values beyond their type, especially for cross field invariants that no schema can express. Their pitfall is brittleness, a constraint encoding an assumption that was only ever approximately true (every order has a positive amount, every user has exactly one country) will fire on legitimate edge cases and erode trust.
Reach for statistical validation to catch the collectively wrong data that passes every per record check. Its pitfalls are the threshold problem and the significance trap. Tune thresholds against replayed history rather than guessing, prefer effect size metrics such as PSI and JSD over raw \(p\) values for production gates, and treat most statistical checks as monitoring signals rather than hard blocks.
Reach for data unit tests to verify the transformation code itself, in continuous integration, against fixed fixtures. Their pitfall is fixture rot, a fixture that no longer resembles production data will pass while the real pipeline breaks, so refresh fixtures from sampled production records and include the edge cases that past incidents revealed.
The deepest pitfall spans all four layers: a validation suite that fires constantly on benign variation trains a team to ignore it, which is strictly worse than no validation at all. Calibrated coverage, where every firing check reliably signals a real problem, is the goal, not maximal coverage.
62.9 References
Bantilan, N. “pandera: Statistical Data Validation of Pandas Dataframes.” Proceedings of the 19th Python in Science Conference (SciPy 2020), 116-124. https://doi.org/10.25080/Majora-342d178e-010
Martinussen, J. G., and Aarholt, T. Patito: A Dataframe Modelling Library Built on Polars and Pydantic. https://github.com/JakobGM/patito
Iannone, R. Pointblank for Python: Data Validation Made Beautiful and Powerful. Posit Software. https://github.com/posit-dev/pointblank
Soliev, A. Validoopsie: A Simple and Light Data Validation Library. https://github.com/akmalsoliev/Validoopsie
Albert, A., Elsner, D., and Borchert, O. Dataframely: A Declarative, Polars Native Data Frame Validation Library. QuantCo. https://github.com/quantco/dataframely
Vink, R., et al. Polars. Zenodo. https://doi.org/10.5281/zenodo.7697217
Gorelli, M., et al. Narwhals: A Lightweight Compatibility Layer Between Dataframe Libraries. https://github.com/narwhals-dev/narwhals
Great Expectations Documentation. https://docs.greatexpectations.io/
Great Expectations, “Data Docs.” https://docs.greatexpectations.io/docs/reference/learn/terms/data_docs/
Amazon Deequ: Unit Tests for Data. https://github.com/awslabs/deequ
Evidently AI Documentation. https://docs.evidentlyai.com/
MacIver, D. R., Hatfield-Dodds, Z., et al. “Hypothesis: A New Approach to Property-Based Testing.” Journal of Open Source Software 4(43), 1891, 2019. https://doi.org/10.21105/joss.01891
Schelter, S., Lange, D., Schmidt, P., Celikel, M., Biessmann, F., and Grafberger, A. “Automating Large-Scale Data Quality Verification.” Proceedings of the VLDB Endowment 11(12), 1781-1794, 2018. https://doi.org/10.14778/3229863.3229867
Breck, E., Polyzotis, N., Roy, S., Whang, S. E., and Zinkevich, M. “Data Validation for Machine Learning.” Proceedings of Machine Learning and Systems (MLSys), 2019. https://mlsys.org/Conferences/2019/doc/2019/167.pdf
Polyzotis, N., Roy, S., Whang, S. E., and Zinkevich, M. “Data Lifecycle Challenges in Production Machine Learning: A Survey.” ACM SIGMOD Record 47(2), 17-28, 2018. https://doi.org/10.1145/3299887.3299891
Massey, F. J. “The Kolmogorov-Smirnov Test for Goodness of Fit.” Journal of the American Statistical Association, 1951. https://doi.org/10.1080/01621459.1951.10500769
Lin, J. “Divergence Measures Based on the Shannon Entropy.” IEEE Transactions on Information Theory, 1991. https://doi.org/10.1109/18.61115
Gama et al., “A Survey on Concept Drift Adaptation.” ACM Computing Surveys, 2014. https://doi.org/10.1145/2523813
# Data Validation and TestingMachine learning systems inherit the quality of the data that flows through them. A model trained on corrupted features, a feature store populated with stale records, or a serving pipeline fed malformed input will produce confident and wrong predictions. Unlike a software bug that throws an exception, a data quality defect often passes silently through every layer of a system until it surfaces as degraded business metrics weeks later. Data validation and testing exist to make these defects loud, early, and cheap to fix.This chapter treats data as a first class artifact that deserves the same engineering discipline applied to source code. We examine the spectrum of validation techniques, from rigid schema enforcement to probabilistic statistical checks, run five open source validation libraries (Pandera, Patito, Pointblank, Validoopsie, and Dataframely) against one shared defective batch, and discuss how validation behaves differently in batch and streaming production pipelines. Where the topic warrants precision, we state the underlying mathematics: the drift statistics in particular have exact definitions and properties that determine when each is appropriate.::: callout-note## What this chapter coversA layered model of data testing. Schema validation pins down structure, constraint checks encode domain logic, statistical validation guards distributions, and data unit tests verify the transformation code itself. A hands on tooling section then expresses one contract in five libraries, executes each against the same batch of 1,000 records with seven planted defects, and compares what each one catches, isolates, and reports. We then place these checks in a production pipeline, formalize the drift metrics that statistical validation relies on, and close with a pragmatic strategy for growing a validation suite without drowning in maintenance.:::## Why Data Validation Matters### The Silent Failure ModeTraditional software fails loudly. A null pointer dereference crashes, a type mismatch refuses to compile, and a failed assertion halts execution. Data pipelines fail quietly. A column that should range from zero to one hundred suddenly contains values in the millions because an upstream service changed its units from dollars to cents. The pipeline does not crash. It computes averages, trains models, and serves predictions, all built on a silent defect.The cost of a data defect grows with the distance it travels before detection. Catching a malformed record at ingestion costs a retry. Catching it after it has trained a production model costs a retraining cycle, a redeployment, and possibly an incident review. Validation pushes detection as close to the source as possible.### Data Quality DimensionsPractitioners commonly decompose data quality into several dimensions. **Completeness** asks whether required values are present. **Validity** asks whether values conform to their expected type and format. **Consistency** asks whether related values agree with each other across tables or time. **Accuracy** asks whether values reflect reality, which is the hardest dimension to test automatically. **Timeliness** asks whether data arrived within its expected window. **Uniqueness** asks whether records that should be distinct are in fact distinct.A mature validation strategy maps each dimension to concrete, automatable checks. Completeness becomes a null count threshold. Validity becomes a type and regex check. Uniqueness becomes a primary key duplication check. Accuracy usually requires a trusted reference source or human review, so teams often approximate it with plausibility bounds.It helps to see these dimensions as predicates over a dataset. Let $D$ be a dataset, viewed as a multiset of records drawn from a record space $\mathcal{R}$. A check is a function $c : \mathcal{R}^{*} \to \{0, 1\}$ that returns $1$ when the dataset satisfies the property and $0$ otherwise. Schema and constraint checks are *deterministic* predicates: they evaluate the same way every time on the same input. Statistical checks are *distributional* predicates: they compare a summary of $D$ against a reference and pass only when the discrepancy stays within a tolerance. This distinction, deterministic versus distributional, is the organizing axis of the chapter, because it dictates how a check is written, where it can run, and how its failures should be interpreted.## Schema Validation### What a Schema CapturesA schema is a contract that describes the structure of a dataset: the set of columns, their names, their data types, their nullability, and sometimes their permissible value ranges. Schema validation verifies that an incoming dataset honors this contract before any downstream code touches it.Schema validation is the cheapest and highest leverage form of data testing because it catches an entire class of structural defects with a single declarative specification. When an upstream team renames a column, drops a field, or changes an integer to a string, a schema check fails immediately rather than letting the change propagate.### Explicit Schemas with PanderaPandera lets you express a schema as code over pandas, Polars, PySpark, and other dataframe engines, with one import path per engine (`pandera.pandas`, `pandera.polars`, and so on). A `DataFrameSchema` declares columns with types and constraints, and validation either returns the validated frame or raises a `SchemaError` describing exactly which rows and checks failed. Section 6 executes this style of schema, along with four alternative libraries, on a live batch.```pythonimport pandera.pandas as pafrom pandera.pandas import Column, Checkschema = pa.DataFrameSchema({"user_id": Column(int, Check.gt(0), unique=True),"age": Column(int, Check.in_range(0, 120), nullable=False),"email": Column(str, Check.str_matches(r".+@.+\..+")),"signup_country": Column(str, Check.isin(["US", "CA", "GB", "DE"])),})validated = schema.validate(raw_df, lazy=True)```The `lazy=True` flag is important in practice. Without it, validation stops at the first failure. With it, Pandera collects every failing check across the frame and reports them together, which dramatically shortens the debugging loop when an unfamiliar dataset arrives.### Class Based Schemas and Type IntegrationPandera also offers a class based API using `DataFrameModel`, which reads like a dataclass and integrates with static type checkers. This style keeps the schema definition close to the rest of your typed code and supports inheritance for shared field definitions.```pythonimport pandera.pandas as pafrom pandera.pandas import DataFrameModel, Fieldclass Transaction(DataFrameModel): amount: float= Field(ge=0, le=1_000_000) currency: str= Field(isin=["USD", "EUR", "GBP"]) timestamp: pa.DateTime = Field(nullable=False)class Config: strict =True# reject unexpected columns```The `strict = True` configuration is a deliberate choice. It rejects any column not declared in the schema, which protects against silent additions of unexpected fields. The opposite default, permissive validation, is more forgiving but lets schema drift accumulate unnoticed.### Serialized Schemas for Cross Language PipelinesWhen a pipeline spans multiple languages or services, an in code Python schema does not travel well. Serialized schema formats such as JSON Schema, Avro, and Protobuf describe structure in a language neutral way. A producer written in Java and a consumer written in Python can share one Avro schema and enforce it at the boundary. Schema registries, common in Kafka deployments, store these definitions centrally and enforce compatibility rules so that a producer cannot publish a breaking change without explicit approval.## Constraint Checks### Beyond StructureSchema validation confirms that a column is an integer. Constraint checks confirm that the integer makes sense. A column may be a perfectly valid float and still be wrong if it represents an age of negative five or a probability of one hundred. Constraint checks encode domain knowledge that no type system can express.Common constraint categories include range checks (a value falls within known bounds), set membership checks (a category belongs to an allowed list), uniqueness checks (a key does not repeat), referential checks (a foreign key exists in a parent table), and cross column checks (a discount price does not exceed a list price).### Cross Field and Cross Row ConstraintsThe most valuable constraints often span multiple fields. A record where `end_date` precedes `start_date` is structurally valid but logically impossible. A row where `total` does not equal the sum of its line items signals a computation bug upstream. These relationships are invisible to per column validation and require checks that reason about the whole record.```pythonschema = pa.DataFrameSchema( columns={"start_date": Column(pa.DateTime),"end_date": Column(pa.DateTime), }, checks=Check(lambda df: df["end_date"] >= df["start_date"], error="end_date must not precede start_date", ),)```### Choosing Hard versus Soft ConstraintsNot every constraint violation should halt a pipeline. A hard constraint, such as a null primary key, justifies rejecting the batch outright because downstream joins will break. A soft constraint, such as a slightly elevated null rate in an optional field, may warrant a warning and a logged metric rather than a full stop. Encoding this distinction explicitly prevents two failure modes: a brittle pipeline that halts on trivial issues, and a permissive pipeline that ignores real corruption. Most validation tools support both raising errors and emitting warnings, and the choice should reflect the genuine downstream impact of each rule.## Statistical Validation### From Deterministic Rules to DistributionsSchema and constraint checks are deterministic: a value either satisfies the rule or it does not. Many real defects are statistical. The data is individually valid but collectively wrong. A feature whose mean shifts by three standard deviations, a categorical column whose distribution suddenly skews toward one value, or a join that quietly drops half its rows all produce data that passes every per row check while being deeply broken.Statistical validation compares the shape of incoming data against an expected profile, usually derived from historical reference data. Rather than asking "is this value valid," it asks "does this dataset look like the data we expect."### Distributional Checks and DriftThe core technique is to summarize a column with statistics, the mean, standard deviation, quantiles, null fraction, and cardinality, then compare those summaries against a baseline. When the deviation exceeds a threshold, the check fails or raises an alert. Drift is the phenomenon being detected: the distribution of a column in the current window has moved away from the distribution it had in a trusted reference window. Several standard metrics quantify how far it has moved, and they differ in what they assume about the data and in how they weight discrepancies.**Kolmogorov Smirnov statistic.** For a continuous column, the two sample Kolmogorov Smirnov (KS) statistic measures the largest gap between two empirical cumulative distribution functions. If $F_r$ is the empirical CDF of the reference sample and $F_c$ that of the current sample, then$$D_{\mathrm{KS}} = \sup_{x} \, \bigl| F_r(x) - F_c(x) \bigr|.$$The statistic lies in $[0, 1]$, is zero only when the two empirical CDFs coincide, and is invariant to any monotone reparameterization of $x$ because it depends only on ranks. Under the null hypothesis that both samples come from the same continuous distribution, the rescaled statistic has a known limiting distribution, which yields a $p$ value. The KS test is sensitive to shifts in location and scale but comparatively insensitive to differences in the tails, since the supremum of the CDF gap is usually attained near the center of the distribution.**Chi squared test.** For a categorical column with $k$ categories, partition both samples into category counts. Let $O_i$ be the observed count of category $i$ in the current sample and $E_i$ the count expected if the current sample followed the reference proportions. The statistic$$\chi^2 = \sum_{i=1}^{k} \frac{(O_i - E_i)^2}{E_i}$$follows, under the null hypothesis of no change, an approximate chi squared distribution with $k - 1$ degrees of freedom. The approximation degrades when expected counts are small, a common rule of thumb requires $E_i \geq 5$ for every category, so rare categories should be merged before applying the test.**Population stability index.** The population stability index (PSI) is the workhorse of production drift monitoring because it produces a single, threshold friendly number. Bin the column into $B$ buckets (for a continuous column, fixed quantile edges from the reference; for a categorical column, one bucket per category). Let $p_i$ be the fraction of reference records in bucket $i$ and $q_i$ the fraction of current records in the same bucket. Then$$\mathrm{PSI} = \sum_{i=1}^{B} (q_i - p_i) \, \ln\!\frac{q_i}{p_i}.$$PSI is symmetric in its two arguments and non negative, reaching zero only when $q_i = p_i$ for every bucket. It is in fact the symmetrized relative entropy between the two binned distributions, sometimes written $\mathrm{PSI} = D_{\mathrm{KL}}(q \,\|\, p) + D_{\mathrm{KL}}(p \,\|\, q)$, where $D_{\mathrm{KL}}$ is the Kullback Leibler divergence. A widely used convention treats $\mathrm{PSI} < 0.1$ as no material shift, $0.1 \leq \mathrm{PSI} < 0.25$ as a moderate shift worth investigating, and $\mathrm{PSI} \geq 0.25$ as a significant shift. Empty buckets force a division by zero, so implementations add a small floor $\epsilon$ to every $p_i$ and $q_i$, which is why bucket counts and the choice of $\epsilon$ must be fixed and recorded for the metric to be reproducible.**Jensen Shannon divergence.** When you want a bounded, smooth distance between two probability distributions $P$ and $Q$, the Jensen Shannon divergence is a good choice. With $M = \tfrac{1}{2}(P + Q)$ the average distribution,$$\mathrm{JSD}(P \,\|\, Q) = \tfrac{1}{2} D_{\mathrm{KL}}(P \,\|\, M) + \tfrac{1}{2} D_{\mathrm{KL}}(Q \,\|\, M).$$Unlike KL divergence, JSD is symmetric, is always finite (the averaged $M$ never assigns zero probability where $P$ or $Q$ is positive), and lies in $[0, \ln 2]$ when using natural logarithms or $[0, 1]$ when using base two logarithms. Its square root is a true metric, the Jensen Shannon distance, which makes it convenient when drift scores are compared or thresholded across many columns.The practical takeaway is that these metrics answer slightly different questions. KS and chi squared come with hypothesis tests and $p$ values, which tempt teams to treat drift as a binary significance decision; but with large samples even a trivial, harmless shift becomes statistically significant, so a raw $p$ value is a poor production gate. PSI and JSD instead report an effect size on a fixed scale, which is what a monitoring dashboard actually needs.```pythonfrom scipy.stats import ks_2sampstat, p_value = ks_2samp(reference["feature"], current["feature"])if p_value <0.01:raise DataDriftError(f"feature distribution shifted, KS p={p_value:.4f}")```### A Worked PSI CalculationSuppose a feature is bucketed into four bins. The reference window and the current window distribute their mass across those bins as follows.| Bucket | Reference $p_i$ | Current $q_i$ | $q_i - p_i$ | $\ln(q_i / p_i)$ | Contribution ||--------|-----------------|---------------|-------------|------------------|--------------|| 1 | 0.40 | 0.25 | -0.15 | -0.470 | 0.0705 || 2 | 0.30 | 0.30 | 0.00 | 0.000 | 0.0000 || 3 | 0.20 | 0.25 | 0.05 | 0.223 | 0.0112 || 4 | 0.10 | 0.20 | 0.10 | 0.693 | 0.0693 |Summing the contributions gives $\mathrm{PSI} \approx 0.151$. By the conventional bands this falls in the moderate range, $0.1 \leq \mathrm{PSI} < 0.25$, so the column warrants investigation but does not on its own justify halting the pipeline. Notice that bucket 2 contributes exactly zero because its mass is unchanged, while bucket 4 contributes heavily despite a modest absolute change of $0.10$, because the log ratio amplifies a doubling of probability. This asymmetric weighting, large relative changes in small buckets dominate the score, is the defining behavior of PSI and the reason rare buckets must be sized carefully.### The Threshold ProblemStatistical validation introduces a tuning challenge absent from deterministic checks. Set thresholds too tight and the pipeline alerts on normal seasonal variation, training the team to ignore alarms. Set them too loose and genuine drift slips through. There is no universally correct threshold. Effective teams derive thresholds empirically by replaying historical data and measuring how often each candidate threshold would have fired, then tuning so that alerts correlate with real incidents rather than noise. Statistical checks should be treated as monitoring signals that often warrant investigation rather than hard gates that block deployment.### Validating Against Reference DataA powerful pattern compares a derived dataset against an independent source of truth. If a feature pipeline computes daily active users, an independent analytics warehouse should report a closely matching count. Reconciliation checks of this kind catch logic bugs that no internal consistency check could find, because they compare two independent computations of the same quantity.## Data Unit Tests### Treating Data Logic Like CodeThe transformations that produce data are code, and code deserves unit tests. A data unit test runs a transformation against a small, fixed input and asserts an exact expected output. Unlike validation, which runs against live production data, a data unit test runs in continuous integration against synthetic fixtures and verifies that the transformation logic itself is correct.```pythondef test_revenue_excludes_refunds(): input_df = pd.DataFrame({"order_id": [1, 2, 3],"amount": [100, 50, 30],"status": ["paid", "refunded", "paid"], }) result = compute_revenue(input_df)assert result ==130# refunded order excluded```This test pins the business rule that refunds do not count toward revenue. If a future change accidentally includes refunds, the test fails in continuous integration before the bad logic ever reaches production data.### Fixtures, Edge Cases, and Property Based TestingGood data unit tests deliberately include edge cases: empty inputs, single row inputs, nulls in every nullable field, duplicate keys, and boundary values. Property based testing libraries such as Hypothesis generate many randomized inputs and check that invariants hold across all of them, which surfaces edge cases a human would not think to enumerate.A useful property to assert is that a transformation never introduces nulls into a non nullable output column, or that an aggregation never produces a count larger than its input row count. These structural invariants hold regardless of the specific input values. Several validation libraries can generate such inputs directly from the schema, so fixtures stay in sync with the contract: Patito's `Model.examples` fills in valid defaults around the columns a test specifies, and Dataframely's `Schema.sample` draws extreme but valid frames that respect every rule, both demonstrated in Section 6.### The dbt Testing ModelIn analytics engineering, dbt has popularized declarative data tests defined in YAML alongside model definitions. Built in generic tests cover the most common assertions, and custom tests are expressed as SQL queries that should return zero rows, where any returned row represents a failure.```yamlmodels:-name: orderscolumns:-name: order_idtests:-unique-not_null-name: statustests:-accepted_values:values:["placed","shipped","delivered","returned"]```This model blurs the line between unit testing and production validation. The same assertions run during development against sample data and in production against the full warehouse, which is one of dbt's most practical strengths.## Tooling: Five Open Source Validators, Side by SideThe ideas of Sections 2 through 5 pay off only once they are wired into code that runs on every batch. The Python ecosystem now offers several mature, MIT licensed libraries for this, and they make genuinely different design choices about how a contract is written, what happens when it is broken, and what evidence is left behind. This section runs five of them, **Pandera**, **Patito**, **Pointblank**, **Validoopsie**, and **Dataframely**, against one contract and one defective batch, so that their differences show up as differences in output rather than in feature lists. All five accept Polars frames. Patito and Dataframely are built directly on Polars; Pandera has a dedicated Polars backend alongside pandas, PySpark, and Ibis; Pointblank and Validoopsie reach Polars, pandas, and other engines through the Narwhals dataframe compatibility layer.### A Shared Contract and a Defective BatchSection 1.2 described a check as a predicate over a dataset. To compare libraries we need one more piece of notation. Let the batch be $D = (r_1, \dots, r_n)$ and the contract a set of $K$ checks, and write $F_k \subseteq \{1, \dots, n\}$ for the set of rows that violate check $k$. Most checks are *row local*: whether row $j$ violates "age lies in $[0, 120]$" depends on $r_j$ alone. Uniqueness is not. Row $j$ violates "user_id is unique" exactly when some other row $i \neq j$ carries the same key, so $F_{\text{uniq}}$ is either empty or has at least two elements, and it can only be computed by looking at the whole column. This distinction explains several of the differences between libraries below.A quarantine policy (Section 7.2) diverts the union of the failure sets and promotes the rest,$$Q = \bigcup_{k=1}^{K} F_k, \qquad |Q| \le \sum_{k=1}^{K} |F_k|,$$with equality exactly when no row breaks two rules at once. A soft check with tolerance $\tau_k$ passes when its failure fraction stays at or below that tolerance, $|F_k| / n \le \tau_k$. This is how Pointblank and Validoopsie encode the hard versus soft distinction of Section 3.3: a hard check is simply one with $\tau_k = 0$.The batch is 1,000 synthetic user records. After generating a clean frame, the cell injects seven defects at known rows, one per rule: a duplicated key, an impossible age, a missing age, a malformed email, a country outside the allowed set, an activity date that precedes signup, and a negative spend.```{python}#| label: code-055-batchfrom datetime import date, timedeltaimport numpy as npimport polars as plrng = np.random.default_rng(55)n =1_000EMAIL_RE =r"^[\w.+-]+@[\w-]+\.[\w.]+$"ALLOWED = ["US", "CA", "GB", "DE"]signup = [date(2024, 1, 1) + timedelta(days=int(d)) for d in rng.integers(0, 365, n)]clean = pl.DataFrame({"user_id": np.arange(1, n +1, dtype=np.int64),"age": rng.integers(18, 90, n, dtype=np.int64),"email": [f"user{i}@example.com"for i inrange(1, n +1)],"country": rng.choice(ALLOWED, n).tolist(),"signup_date": signup,"last_active": [s + timedelta(days=int(d)) for s, d inzip(signup, rng.integers(0, 400, n))],"spend": np.round(rng.gamma(shape=2.0, scale=40.0, size=n), 2),})# Inject one defect per rule at known row positions.row = pl.int_range(pl.len())batch = clean.with_columns( user_id=pl.when(row ==10).then(pl.lit(10, pl.Int64)).otherwise("user_id"), age=pl.when(row ==100).then(250).when(row ==200).then(None).otherwise("age"), email=pl.when(row ==300).then(pl.lit("bob_at_example.com")).otherwise("email"), country=pl.when(row ==400).then(pl.lit("FR")).otherwise("country"), last_active=pl.when(row ==500) .then(pl.col("signup_date") - pl.duration(days=30)) .otherwise("last_active"), spend=pl.when(row ==600).then(-12.5).otherwise("spend"),)batch.slice(8, 4)```Rows 9 and 10 now share `user_id` 10, and the other six defects sit at rows 100 through 600. Before reaching for any library, it is worth writing the contract in the plainest form available: one Polars expression per rule that evaluates to true on a violation. This reference implementation is the ground truth against which the five libraries are checked, and because it uses nothing but expressions it ports directly to Julia and Rust.::: {.panel-tabset}## Python```{python}#| label: code-055-reference# Library free reference: each rule is an expression that is True on a violation.RULES = {"user_id unique": pl.col("user_id").is_duplicated(),"age not null": pl.col("age").is_null(),"age in [0, 120]": ~pl.col("age").is_between(0, 120),"email format": ~pl.col("email").str.contains(EMAIL_RE),"country allowed": ~pl.col("country").is_in(ALLOWED),"active after signup": pl.col("last_active") < pl.col("signup_date"),"spend >= 0": pl.col("spend") <0,}HARD = {"user_id unique", "age not null", "active after signup"}flags = {name: expr.fill_null(False) for name, expr in RULES.items()}quarantine = batch.with_row_index("row").filter(pl.any_horizontal(list(flags.values())))truth = ( batch.select(**flags) .sum() .transpose(include_header=True, header_name="rule", column_names=["rows_failing"]) .with_columns(severity=pl.when(pl.col("rule").is_in(list(HARD))).then(pl.lit("hard")).otherwise(pl.lit("soft"))))print(f"{truth.height} rules violated; quarantine rows: {quarantine['row'].to_list()}")truth```## Julia```julia# Illustrative: the same seven rules over DataFrames.jl.# `batch` exported from Python with batch.write_parquet("users_batch.parquet").usingDataFrames, Dates, Parquet2const EMAIL_RE =r"^[\w.+-]+@[\w-]+\.[\w.]+$"const ALLOWED = Set(["US", "CA", "GB", "DE"])batch =DataFrame(Parquet2.Dataset("users_batch.parquet"); copycols =false)functionviolations(df::AbstractDataFrame)DataFrame("user_id unique"=>nonunique(df, :user_id; keep =:noduplicates),"age not null"=>ismissing.(df.age),"age in [0, 120]"=>coalesce.((df.age .<0) .| (df.age .>120), false),"email format"=> .!occursin.(Ref(EMAIL_RE), df.email),"country allowed"=> .!in.(df.country, Ref(ALLOWED)),"active after signup"=> df.last_active .< df.signup_date,"spend >= 0"=> df.spend .<0, )endv =violations(batch)truth = [String(name) =>sum(col) for (name, col) inpairs(eachcol(v))]quarantine =findall(any.(eachrow(v))) .-1# zero based, to match Polarsprintln(truth)println("quarantine rows: ", quarantine)```## Rust```rust// Illustrative: the same seven rules as Polars expressions in Rust.// Cargo.toml: polars = { version ="0.51", features = ["lazy", "parquet", "regex",//"strings", "is_unique", "dtype-date"] }use polars::prelude::*;const EMAIL_RE:&str = r"^[\w.+-]+@[\w-]+\.[\w.]+$";const ALLOWED: [&str; 4] = ["US", "CA", "GB", "DE"];fn rules() -> Vec<Expr> { let allowed = ALLOWED.iter().fold(lit(false), |acc, c|acc.or(col("country").eq(lit(*c)))); vec![col("user_id").is_duplicated().alias("user_id unique"),col("age").is_null().alias("age not null"),col("age").lt(lit(0)).or(col("age").gt(lit(120))).alias("age in [0, 120]"),col("email").str().contains(lit(EMAIL_RE), true).not().alias("email format"),allowed.not().alias("country allowed"),col("last_active").lt(col("signup_date")).alias("active after signup"),col("spend").lt(lit(0.0)).alias("spend >= 0"), ]}fn main() -> PolarsResult<()> { let batch = LazyFrame::scan_parquet("users_batch.parquet", ScanArgsParquet::default())?; let flags: Vec<Expr>=rules().into_iter().map(|e|e.fill_null(lit(false))).collect(); let truth = batch.clone().select(flags.iter().cloned().map(|e|e.sum()).collect::<Vec<_>>()).collect()?; println!("{truth}"); let quarantine = batch.with_row_index("row", None).filter(any_horizontal(&flags)?).select([col("row")]).collect()?; println!("quarantine rows: {quarantine}");Ok(())}```:::Seven rules fail. The duplicated key contributes two rows to $F_{\text{uniq}}$ and every other rule contributes one, and since no row breaks two rules the quarantine set has $|Q| = \sum_k |F_k| = 8$ rows. A correct validator must find all seven violations; a validator that quarantines should isolate these eight rows. Following Section 3.3, the three rules marked hard (a duplicated key breaks joins, a missing age and a time reversal signal a corrupt record) should stop the pipeline, while the four soft rules should be recorded without necessarily blocking it.### Pandera: Schemas as Typed ClassesPandera, introduced in Section 2 on pandas frames, provides a Polars backend under `pandera.polars` (Bantilan 2020). A `DataFrameModel` declares one typed attribute per column, `Field` arguments attach built in checks, and a method decorated with `@pa.dataframe_check` expresses the cross column rule. Setting `strict = True` makes an undeclared column a failure in its own right.```{python}#| label: code-055-panderaimport pandera.polars as pafrom pandera.errors import SchemaErrorsclass UserSchema(pa.DataFrameModel): user_id: pl.Int64 = pa.Field(gt=0, unique=True) age: pl.Int64 = pa.Field(in_range={"min_value": 0, "max_value": 120}, nullable=False) email: pl.String = pa.Field(str_matches=EMAIL_RE) country: pl.String = pa.Field(isin=ALLOWED) signup_date: pl.Date last_active: pl.Date spend: pl.Float64 = pa.Field(ge=0)@pa.dataframe_checkdef active_after_signup(cls, data: pa.PolarsData) -> pl.LazyFrame:return data.lazyframe.select(pl.col("last_active") >= pl.col("signup_date"))class Config: strict =True# an unexpected column is itself a failureUserSchema.validate(clean) # the clean frame passes silentlytry: UserSchema.validate(batch, lazy=True)except SchemaErrors as exc: pandera_failures = exc.failure_casespandera_failures.select("column", "check", "index", "failure_case")```With `lazy=True`, Pandera collects every failure into a single `SchemaErrors` exception whose `failure_cases` attribute is itself a Polars frame: one row per failing value, with the check that failed and the row `index` where it failed. Both members of the duplicated key appear, rows 9 and 10, because uniqueness is a property of the whole column, and the cross column rule reports row 500 with the offending record serialized as its failure case. The eight distinct indices are exactly the reference quarantine set.The schema also doubles as a type annotation. Decorating a function with `@pa.check_types` validates its annotated arguments at call time, so a transformation cannot begin on a batch that breaks its input contract. This is the inline, fail fast placement that Section 7.1 recommends after each transformation.```{python}#| label: code-055-pandera-check-typesfrom pandera.typing.polars import DataFrame@pa.check_types(lazy=True)def spend_by_country(users: DataFrame[UserSchema]) -> pl.DataFrame:return users.group_by("country").agg(pl.col("spend").sum().round(2)).sort("country")print(spend_by_country(clean))try: spend_by_country(batch)except SchemaErrors as exc:print(f"rejected before aggregating: {exc.failure_cases.height} failure cases")```### Patito: Pydantic Models for DataFramesPatito reaches the same contract from the other direction: it is written as a Pydantic model, so the class that validates a whole frame is also an ordinary row model that can validate an API payload or a configuration record. Nullability follows Python typing (an `int` field rejects nulls, an `int | None` field accepts them), a `Literal` type replaces an allowed set, and `pt.field` stands for the column being defined when a constraint needs an arbitrary Polars expression.```{python}#| label: code-055-patitofrom typing import Literalimport patito as ptfrom patito.exceptions import DataFrameValidationErrorclass User(pt.Model): user_id: int= pt.Field(gt=0, unique=True) age: int= pt.Field(ge=0, le=120) # not Optional, so a null is an error email: str= pt.Field(pattern=EMAIL_RE) country: Literal["US", "CA", "GB", "DE"] signup_date: date last_active: date = pt.Field(constraints=pt.field >= pl.col("signup_date")) spend: float= pt.Field(ge=0)User.validate(clean)try: User.validate(batch)except DataFrameValidationError as exc: patito_errors = exc.errors()print(exc)```Patito reports all seven violations in the familiar Pydantic error format, grouped by column. The messages carry counts ("2 rows with duplicated values") and, for categorical violations, the offending values, but not row positions: Patito answers whether a frame conforms and leaves locating bad rows to the caller.Its distinctive extra is fixture generation for the data unit tests of Section 5. `Model.examples` builds a valid frame from only the columns a test cares about and fills every other column with a value that satisfies its type and constraints. It cannot invent strings that match a regular expression, so a pattern constrained column such as `email` must be supplied explicitly.```{python}#| label: code-055-patito-examplesfixture = User.examples({"user_id": [1, 2, 3],"email": ["a@x.io", "b@y.io", "c@z.io"],"spend": [0.0, 19.99, 1_000_000.0], # boundary values the test cares about})User.validate(fixture)fixture.select(User.columns)```### Pointblank: Validation Plans with Severity ThresholdsPointblank, from Posit, treats validation as a plan: a chain of named steps, each a check over one or more columns, interrogated together and summarized in a report. Its central idea is graded severity. Every step carries `Thresholds` for *warning*, *error*, and *critical* states, given either as absolute counts of failing rows (integers) or as failure fractions (floats), which is precisely the tolerance $\tau_k$ of Section 6.1. Below, the three hard rules get `critical=1`, so a single failing row is critical, while soft rules escalate from warning at one failing row to error at 1 percent and critical at 5 percent. The plan opens with a schema step whose expected column names and types are read from the clean reference frame.::: {html-table-processing="none"}```{python}#| label: code-055-pointblankimport pointblank as pbHARD_T = pb.Thresholds(critical=1) # one failing row is fatalSOFT_T = pb.Thresholds(warning=1, error=0.01, critical=0.05) # escalate with the ratevalidation = ( pb.Validate(data=batch, tbl_name="users", label="Nightly signup batch", thresholds=SOFT_T) .col_schema_match(schema=pb.Schema(tbl=clean), brief="schema") .rows_distinct(columns_subset=["user_id"], thresholds=HARD_T, brief="user_id unique") .col_vals_not_null(columns="age", thresholds=HARD_T, brief="age not null") .col_vals_between(columns="age", left=0, right=120, na_pass=True, brief="age in [0, 120]") .col_vals_regex(columns="email", pattern=EMAIL_RE, brief="email format") .col_vals_in_set(columns="country", set=ALLOWED, brief="country allowed") .col_vals_ge( columns="last_active", value=pb.col("signup_date"), thresholds=HARD_T, brief="active after signup", ) .col_vals_ge(columns="spend", value=0, brief="spend >= 0") .interrogate())validation.get_tabular_report()```:::The report is a self contained HTML table, built with the Great Tables library, listing every step with its pass and fail counts, failure fractions, and threshold states. It is an artifact a data steward or analyst can read without opening Python, the communication role that Data Docs play in Great Expectations (Section 6.9).Two calls turn the plan into pipeline behavior. `assert_below_threshold` is the gate: asked about the critical level it fails on exactly the three hard rules, while the four soft violations, each one row in a thousand, sit at warning and do not block. `get_sundered_data` is the quarantine, splitting the batch into passing and failing partitions.```{python}#| label: code-055-pointblank-gatetry: validation.assert_below_threshold(level="critical")exceptAssertionErroras exc:print(exc)passed = validation.get_sundered_data(type="pass")failed = validation.get_sundered_data(type="fail")print(f"pass partition: {passed.height} rows, fail partition: {failed.height} rows")print(f"duplicated user_id rows left in the pass partition: {passed['user_id'].is_duplicated().sum()}")```Note what the split does with the duplicated key. `rows_distinct` is a judgment about the table rather than about any single row (the $F_{\text{uniq}}$ case of Section 6.1), so it does not take part in the row level split and both duplicate rows remain in the pass partition. When keys must be enforced at quarantine time, pair Pointblank's report with an explicit deduplication step, or with a library that treats key violations as row failures, such as Pandera or Dataframely.### Validoopsie: Impact Levels and Tolerances on Any BackendValidoopsie is the lightest of the five. It is written against Narwhals, so one validation runs unchanged on pandas, Polars, PyArrow, and the other engines Narwhals supports, and its catalog is organized into themed groups (`NullValidation`, `ValuesValidation`, `StringValidation`, and so on) chained from a single `Validate` object. Each check takes an `impact` level and a fractional `threshold`. Impact sets the consequence when `validate()` runs: a failed high impact check raises, a medium one is logged as an error, and a low one as a warning. The threshold is the tolerance $\tau_k$, and a check whose failure fraction stays within it reports success.For the low impact rules the tolerance should come from the reasoning of Section 4.4 rather than a guess. If a healthy batch fails a soft rule at a benign rate $p_0$, the failure count is binomial with mean $n p_0$ and variance $n p_0 (1 - p_0)$, so a tolerance three standard errors above the benign rate is$$\tau = p_0 + 3 \sqrt{\frac{p_0 (1 - p_0)}{n}}.$$With $p_0 = 0.002$ and $n = 1000$ this gives $\tau \approx 0.0062$, about six failing rows per thousand before the check fails. Validoopsie has no built in check that orders two columns, so the date rule is written as a custom check by subclassing `BaseValidation`. Its `__call__` receives a Narwhals frame and returns the failing values grouped with their counts, which keeps the custom rule backend agnostic as well.```{python}#| label: code-055-validoopsieimport mathimport narwhals as nwfrom validoopsie import Validatefrom validoopsie.base import BaseValidationclass ColumnNotBefore(BaseValidation):"""Fail rows where `column` precedes `other`: a cross column ordering rule."""def__init__(self, column, other, impact="low", threshold=0.0, **kwargs):super().__init__(column, impact, threshold, **kwargs)self.other = other@propertydef fail_message(self) ->str:returnf"'{self.column}' precedes '{self.other}' in some rows."def__call__(self, frame):return ( frame.filter(nw.col(self.column) < nw.col(self.other)) .group_by(self.column) .agg(nw.col(self.column).count().alias(f"{self.column}-count")) )p0 =0.002tau = p0 +3* math.sqrt(p0 * (1- p0) / batch.height)vd = ( Validate(batch) .UniqueValidation.ColumnUniquePair(column_list=["user_id"], impact="high") .NullValidation.ColumnNotBeNull(column="age", impact="high") .add_validation(ColumnNotBefore(column="last_active", other="signup_date", impact="high")) .ValuesValidation.ColumnValuesToBeBetween(column="age", min_value=0, max_value=120, impact="medium") .StringValidation.PatternMatch(column="email", pattern=EMAIL_RE, impact="medium") .UniqueValidation.ColumnUniqueValuesToBeInList( column="country", values=ALLOWED, impact="low", threshold=tau ) .ValuesValidation.ColumnValuesToBeBetween(column="spend", min_value=0, impact="low", threshold=tau))print(f"tolerance for low impact rules: tau = {tau:.4f}")vd_summary = pl.DataFrame([ {"check": key,"impact": res["impact"],"status": res["result"]["status"],"failed": res["result"].get("failed_number", 0),"fraction": res["result"].get("failed_percentage", 0.0), }for key, res in vd.results.items()if key !="Summary"])vd_summary```The summary shows all three tiers at work. The two low impact checks each see one failing row, a fraction of 0.001 that falls inside $\tau$, so they report success while still recording the count for monitoring. The two medium checks fail with zero tolerance but are only logged. Validoopsie logs through Loguru, so routing that log to a compact sink at warning level and above makes the tiers visible: three critical lines for the high impact failures, two error lines for the medium ones, and nothing for the low impact checks that stayed within tolerance. Only the high impact failures stop the pipeline:```{python}#| label: code-055-validoopsie-gateimport sysfrom loguru import loggerlogger.remove()logger.add(sys.stdout, level="WARNING", format="{level: <8}{message}", colorize=False)try: vd.validate()exceptValueErroras exc:print(f"\nraised: {exc}")```Because every check is expressed through Narwhals, the same validation runs on a pandas copy of the batch without modification:```{python}#| label: code-055-validoopsie-pandason_pandas = Validate(batch.to_pandas()).ValuesValidation.ColumnValuesToBeBetween( column="spend", min_value=0, impact="high")on_pandas.results["ColumnValuesToBeBetween_spend"]["result"]```### Dataframely: Polars Native Schemas, Quarantine, and SamplingDataframely, from QuantCo, is Polars native and the most opinionated about types. Columns are non nullable unless declared otherwise, a `primary_key` flag replaces a separate uniqueness check, and cross column logic is a method decorated with `@dy.rule()` that returns a Polars expression evaluated per row. Its central operation is `filter`, which never raises: it returns the rows that satisfy every rule together with a `FailureInfo` object describing the rest. The quarantine pattern of Section 7.2 is built into the API.```{python}#| label: code-055-dataframelyimport dataframely as dyclass UserTable(dy.Schema): user_id = dy.Int64(primary_key=True, min=1) age = dy.Int64(min=0, max=120) # non nullable by default email = dy.String(regex=EMAIL_RE) country = dy.String(check=lambda c: c.is_in(ALLOWED)) signup_date = dy.Date() last_active = dy.Date() spend = dy.Float64(min=0)@dy.rule()def active_after_signup(cls) -> pl.Expr:return pl.col("last_active") >= pl.col("signup_date")valid, failure = UserTable.filter(batch)print(f"{valid.height} rows promoted, {failure.invalid().height} rows quarantined")failure.counts()```The counts match the reference implementation rule for rule, including two rows for the primary key, and the eight quarantined rows are exactly the reference quarantine set. `FailureInfo` can also report which rules fail together (`cooccurrence_counts`) and be written to Parquet for later inspection. When fail fast behavior is wanted instead, `validate` raises with a summary of the same counts:```{python}#| label: code-055-dataframely-validatetry: UserTable.validate(batch)except dy.exc.ValidationError as exc:print(exc)```Two further features set Dataframely apart. A validated frame is typed as `dy.DataFrame[UserTable]`, so a function signature can demand a frame that has already passed the schema and a static type checker flags code that passes an unvalidated one. And `sample` generates synthetic frames that satisfy the entire schema, including the cross column rule and the primary key, which turns the schema into a source of property based test fixtures (Section 5.2). The generator deliberately explores extreme but valid values, very large keys and amounts and dates centuries apart, because those are the inputs that break transformation code. Columns with a custom `check` cannot be sampled automatically, so `country` receives explicit values.```{python}#| label: code-055-dataframely-samplesample = UserTable.sample( num_rows=5, overrides={"country": ["US", "CA", "GB", "DE", "US"]}, generator=dy.random.Generator(seed=55),)print(f"sample satisfies the schema: {UserTable.is_valid(sample)}")sample.select("user_id", "age", "country", "signup_date", "last_active", "spend")```Beyond single tables, a `dy.Collection` groups several schemas and adds rules across them, such as requiring every order to reference an existing user, which brings the referential checks of Section 3.1 into the same declarative framework.### Scorecard: One Contract, Five VerdictsThe cell below gathers what each library reported on the defective batch.```{python}#| label: code-055-scorecardvo_results = [res for key, res in vd.results.items() if key !="Summary"]scorecard = pl.DataFrame({"library": ["reference", "pandera", "patito", "pointblank", "validoopsie", "dataframely"],"rules_flagged": [ truth.height, pandera_failures.select("column", "check").n_unique(),len(patito_errors),sum(count >0for count in validation.n_failed().values()),sum(res["result"].get("failed_number", 0) >0for res in vo_results),len(failure.counts()), ],"rows_isolated": [ quarantine.height, pandera_failures["index"].n_unique(),None, validation.get_sundered_data(type="fail").height,None, failure.invalid().height, ],"default_on_failure": ["none (plain expressions)","raise (all failures with lazy=True)","raise","report; assert by severity level","raise on high impact only","filter splits; validate raises", ],})scorecard```All five libraries flag the same seven rules as the reference. They differ in what they hand back. Pandera and Dataframely isolate exactly the eight rows of the reference quarantine set. Pointblank's row level split isolates six, because its uniqueness step judges the table rather than individual rows. Patito and Validoopsie report rule level verdicts and counts rather than row positions, which is enough for a gate and for monitoring but not, on its own, for quarantine. The broader differences are summarized below.::: {.table-responsive}|| Pandera | Patito | Pointblank | Validoopsie | Dataframely ||---|---|---|---|---|---|| Contract style | Class or object schema | Pydantic model | Chained validation plan | Chained validation plan | Class schema with rules || Engines | pandas, Polars, PySpark, Ibis, and others | Polars | Polars, pandas, SQL databases via Ibis | Any Narwhals backend | Polars || On failure | Raises; collects all with `lazy=True`| Raises with all errors | Reports; asserts by severity on request | Raises for high impact only |`filter` splits; `validate` raises || Severity model | Error, or warning per check | Error only | Warning, error, critical thresholds | Impact level plus tolerance | Error only || Row level output | Failure cases with row index | Counts and values | Pass and fail partitions | Failing values and counts | Valid frame plus `FailureInfo`|| Human facing report | Failure case frame | Pydantic style summary | HTML report table | Text summary table | Rule count summary || Test data generation | Hypothesis strategies (pandas) |`Model.examples`|`generate_dataset`| None built in |`Schema.sample`|| Distinctive extra |`check_types` decorator | One model for rows and frames | YAML plans, step reports | Small custom check API | Multi table `Collection`|:::### Choosing Among ThemThe choice follows the shape of the codebase and the audience for the results. Reach for **Pandera** when one validation vocabulary must span several engines (pandas in notebooks, Polars in pipelines, PySpark at scale) or when typed function boundaries matter, since `check_types` validates at every call. Reach for **Patito** when Pydantic models are already the lingua franca, for example when the same record shape arrives through an API and leaves through a dataframe, and one class should govern both. Reach for **Pointblank** when the people who must act on validation results are analysts, stewards, or managers, when graded severity matters more than pass or fail, or when the data lives in a SQL database. Reach for **Validoopsie** when the goal is a small dependency footprint, backend portability, and tiered severity in a few lines. Reach for **Dataframely** in Polars native pipelines that need quarantine, typed frames, multi table rules, or schema driven test data.The libraries also compose. A realistic arrangement for the zones of Section 7.1 places Dataframely or Pandera at the schema and constraint gates inside transformation code, where typed frames and row level quarantine matter, and Pointblank at the statistical and reporting gate, where results must be read and acted on by people. The cost of combining tools is that the contract is written more than once. Keep one authoritative definition, such as the reference expressions of Section 6.1, and test in continuous integration that every library's translation agrees with it on a fixture batch, exactly as the scorecard does.### Adjacent ToolingGreat Expectations remains the most widely deployed framework in this space. It is built around Expectations, declarative assertions such as `expect_column_values_to_not_be_null`, grouped into suites, run through checkpoints, and rendered as Data Docs, readable HTML reports that serve as both documentation and test results. It suits organizations where many teams share one catalog of expectations, at the cost of more configuration and a heavier conceptual footprint than the five libraries above. Soda offers a declarative checks language aimed at data reliability monitoring. Deequ, built on Spark, brings constraint verification and data profiling to very large datasets and can suggest constraints automatically (Schelter et al. 2018). Evidently focuses on drift and model monitoring with visual reports, the natural companion to the statistical validation of Section 4. The right combination depends on data scale, the prevailing language stack, and whether the emphasis is on batch validation or continuous monitoring.## Validating Data in Production Pipelines### Where to Place Validation GatesValidation in production is about placement as much as technique. The standard pattern places gates at every boundary where data changes ownership: at ingestion, where external data enters the system; after each major transformation, where bugs are introduced; and before serving, where bad data would directly harm users or models. Validating only at the end leaves you knowing that something broke without knowing where. Validating at each boundary localizes failures to a specific stage.A widely used architecture routes data through quality zones, often named for metals. Raw data lands in a bronze zone with minimal validation. Cleaned and conformed data moves to a silver zone after passing schema and constraint checks. Business level aggregates reach a gold zone only after statistical validation confirms they look reasonable. Each promotion between zones is a validation gate, and a record that fails a gate is diverted rather than silently promoted.```{mermaid}flowchart LR SRC["External source"] --> G1{"Schema gate"} G1 -->|"pass"| BRONZE["Bronze zone raw"] G1 -->|"fail"| Q1["Quarantine"] BRONZE --> G2{"Constraint gate"} G2 -->|"pass"| SILVER["Silver zone conformed"] G2 -->|"fail"| Q2["Quarantine"] SILVER --> G3{"Statistical gate"} G3 -->|"pass"| GOLD["Gold zone aggregates"] G3 -->|"alert"| MON["Monitoring and review"] GOLD --> SERVE["Serving and models"]```The gates are ordered from cheapest and strictest to most expensive and most forgiving. The schema gate is a hard structural check at the boundary. The constraint gate adds domain logic. The statistical gate, placed last, is usually a monitoring signal rather than a hard stop, which is why its failure edge routes to review rather than to quarantine.### Fail Fast versus QuarantineWhen validation fails in production, the pipeline must decide what to do with the offending data. Two strategies dominate. The fail fast strategy halts the pipeline, alerts an operator, and prevents bad data from propagating, which is appropriate when downstream consumers cannot tolerate any corruption. The quarantine strategy diverts failing records to a separate location for later inspection while allowing valid records to proceed, which keeps the pipeline flowing when partial data is better than none.The right choice depends on the use case. A financial reconciliation pipeline should fail fast because a wrong number is worse than a late number. A recommendation feature pipeline might quarantine a small fraction of malformed events and continue, because serving slightly incomplete recommendations beats serving none. Encoding this policy explicitly, rather than letting it emerge from where exceptions happen to propagate, is a mark of a mature pipeline. The libraries of Section 6 make the choice an API decision: Pandera's `validate`, Patito's `validate`, and Dataframely's `validate` fail fast, while Dataframely's `filter` and Pointblank's `get_sundered_data` quarantine, returning the passing rows and the failing rows separately.### Batch versus Streaming ValidationBatch pipelines validate a whole dataset at once, which makes statistical checks straightforward because the full distribution is available. Streaming pipelines validate one record or a small window at a time, which constrains the kinds of checks that are feasible. A per record schema check is trivial in a stream. A distributional drift check is not, because no single record reveals a distribution.Streaming systems address this by maintaining rolling windows and incremental statistics, computing approximate distributions over a recent window and comparing them against a baseline. Per record validation handles structural and constraint checks at the edge, while windowed aggregation handles statistical checks slightly behind the live edge. Records that fail structural checks are typically routed to a dead letter queue, the streaming analog of quarantine.### Validation as MonitoringIn production, validation results are themselves a stream of metrics. The null rate of a column, the number of quarantined records, the drift score against baseline, and the pass rate of each expectation suite should all be emitted to a monitoring system and tracked over time. This transforms validation from a binary gate into an observable signal. A slow upward creep in a null rate, invisible to any single threshold check, becomes obvious on a time series chart and lets teams intervene before the rate crosses a failure boundary.Effective teams alert on validation metrics with the same rigor they apply to service latency and error rates. A failing expectation suite should page someone, a rising quarantine rate should open a ticket, and a sustained drift signal should trigger an investigation into whether a model needs retraining.### Versioning Expectations with DataValidation logic evolves as understanding of the data deepens. New constraints get added, thresholds get tuned, and obsolete checks get removed. Because validation suites are code, they belong in version control alongside the pipeline they protect, reviewed through the same pull request process. When a validation rule changes, the change history explains why, which is invaluable during an incident review months later. Treating expectations as versioned artifacts also enables a disciplined workflow: propose a new constraint, test it against historical data to estimate its failure rate, and only then promote it to a blocking gate.## Building a Validation Strategy### Start at the BoundariesA team cannot validate everything at once, and attempting to do so produces a brittle thicket of checks that nobody maintains. The pragmatic starting point is to enforce schemas at the boundaries where external data enters the system, because that is where the most defects originate and where a single check protects the most downstream code. Schema enforcement at ingestion delivers the highest return for the least effort.### Layer in Depth Over TimeOnce boundary schemas are in place, layer additional validation where incidents reveal it is needed. Each production data incident is a lesson that should become a permanent check, so that the same defect can never recur silently. This incident driven growth keeps the validation suite focused on real risks rather than hypothetical ones, and it ensures that every check earns its maintenance cost by guarding against a failure that actually happened.### Balancing Coverage and MaintenanceEvery validation rule is code that must be maintained, and an overzealous suite that fires constantly on benign variation trains a team to ignore it, which is worse than having no validation at all. The goal is not maximal coverage but calibrated coverage, where each check that fires reliably signals a real problem worth a human's attention. Periodically auditing which checks have fired, which have never fired, and which fire so often they are ignored keeps the suite healthy and trusted.### When to Use Each Layer, and the Pitfalls of EachThe four validation layers are not interchangeable, and a healthy strategy reaches for each where it is strongest.Reach for **schema validation** at every boundary where data enters or changes ownership. It is cheap, declarative, and catches the structural defects that cause the loudest downstream failures. Its pitfall is over strictness: a schema that rejects any unexpected column will halt a pipeline when an upstream team adds a harmless field, so reserve strict mode for boundaries where unexpected columns genuinely signal a problem.Reach for **constraint checks** when domain logic constrains values beyond their type, especially for cross field invariants that no schema can express. Their pitfall is brittleness, a constraint encoding an assumption that was only ever approximately true (every order has a positive amount, every user has exactly one country) will fire on legitimate edge cases and erode trust.Reach for **statistical validation** to catch the collectively wrong data that passes every per record check. Its pitfalls are the threshold problem and the significance trap. Tune thresholds against replayed history rather than guessing, prefer effect size metrics such as PSI and JSD over raw $p$ values for production gates, and treat most statistical checks as monitoring signals rather than hard blocks.Reach for **data unit tests** to verify the transformation code itself, in continuous integration, against fixed fixtures. Their pitfall is fixture rot, a fixture that no longer resembles production data will pass while the real pipeline breaks, so refresh fixtures from sampled production records and include the edge cases that past incidents revealed.The deepest pitfall spans all four layers: a validation suite that fires constantly on benign variation trains a team to ignore it, which is strictly worse than no validation at all. Calibrated coverage, where every firing check reliably signals a real problem, is the goal, not maximal coverage.## References1. Bantilan, N. "pandera: Statistical Data Validation of Pandas Dataframes." Proceedings of the 19th Python in Science Conference (SciPy 2020), 116-124. https://doi.org/10.25080/Majora-342d178e-0102. Pandera Documentation. https://pandera.readthedocs.io/3. Martinussen, J. G., and Aarholt, T. Patito: A Dataframe Modelling Library Built on Polars and Pydantic. https://github.com/JakobGM/patito4. Iannone, R. Pointblank for Python: Data Validation Made Beautiful and Powerful. Posit Software. https://github.com/posit-dev/pointblank5. Soliev, A. Validoopsie: A Simple and Light Data Validation Library. https://github.com/akmalsoliev/Validoopsie6. Albert, A., Elsner, D., and Borchert, O. Dataframely: A Declarative, Polars Native Data Frame Validation Library. QuantCo. https://github.com/quantco/dataframely7. Vink, R., et al. Polars. Zenodo. https://doi.org/10.5281/zenodo.76972178. Gorelli, M., et al. Narwhals: A Lightweight Compatibility Layer Between Dataframe Libraries. https://github.com/narwhals-dev/narwhals9. Great Expectations Documentation. https://docs.greatexpectations.io/10. Great Expectations, "Data Docs." https://docs.greatexpectations.io/docs/reference/learn/terms/data_docs/11. dbt Tests Documentation. https://docs.getdbt.com/docs/build/data-tests12. Soda Core Documentation. https://docs.soda.io/13. Amazon Deequ: Unit Tests for Data. https://github.com/awslabs/deequ14. Evidently AI Documentation. https://docs.evidentlyai.com/15. MacIver, D. R., Hatfield-Dodds, Z., et al. "Hypothesis: A New Approach to Property-Based Testing." Journal of Open Source Software 4(43), 1891, 2019. https://doi.org/10.21105/joss.0189116. Schelter, S., Lange, D., Schmidt, P., Celikel, M., Biessmann, F., and Grafberger, A. "Automating Large-Scale Data Quality Verification." Proceedings of the VLDB Endowment 11(12), 1781-1794, 2018. https://doi.org/10.14778/3229863.322986717. Breck, E., Polyzotis, N., Roy, S., Whang, S. E., and Zinkevich, M. "Data Validation for Machine Learning." Proceedings of Machine Learning and Systems (MLSys), 2019. https://mlsys.org/Conferences/2019/doc/2019/167.pdf18. Polyzotis, N., Roy, S., Whang, S. E., and Zinkevich, M. "Data Lifecycle Challenges in Production Machine Learning: A Survey." ACM SIGMOD Record 47(2), 17-28, 2018. https://doi.org/10.1145/3299887.329989119. Apache Avro Schema Specification. https://avro.apache.org/docs/current/specification/20. Confluent Schema Registry Documentation. https://docs.confluent.io/platform/current/schema-registry/index.html21. Massey, F. J. "The Kolmogorov-Smirnov Test for Goodness of Fit." Journal of the American Statistical Association, 1951. https://doi.org/10.1080/01621459.1951.1050076922. Lin, J. "Divergence Measures Based on the Shannon Entropy." IEEE Transactions on Information Theory, 1991. https://doi.org/10.1109/18.6111523. Gama et al., "A Survey on Concept Drift Adaptation." ACM Computing Surveys, 2014. https://doi.org/10.1145/2523813