Validating Document Data with Schemas

Extraction is lossy in ways that do not raise exceptions. A PDF table parses, but one column shifts by a row. A spreadsheet reads, but the date column arrives as text on the third file of the month. An OCR pass returns 1O4.5O instead of 104.50. Every one of those produces a DataFrame with the right shape and wrong contents, and every downstream step — the merge, the aggregate, the report — happily processes it.

A schema is the assertion that turns those silent corruptions into loud failures at the point they enter the pipeline. Ad-hoc checks fail here for a familiar reason: they are written for the errors already seen, live scattered through the transform code, and are quietly dropped when someone refactors. A declared schema is one object that states the contract for the whole dataset, runs as a single call, and reports every violation at once.

Prerequisites

python -m venv .venv
source .venv/bin/activate

pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23" "pydantic>=2.7,<3"

mkdir -p data out/quarantine
pandas==2.2.3
pandera==0.22.1
pydantic==2.9.2

The two libraries answer different questions. pandera validates a table: column dtypes, value ranges, uniqueness, cross-column relationships, and dataframe-level invariants such as "the rows sum to the stated total". pydantic validates a record: one invoice, one form submission, with coercion and nested structure. Document pipelines usually need both — pydantic where records arrive one at a time from a form or an API, pandera once they are a frame.

Step 1: Diagnose What the Data Actually Contains

Write the schema against reality, not against the specification. Profile a few real extractions first.

# pip install "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd

EXTRACT = Path("data/extracted_invoices.csv")

def profile(csv_path: Path) -> pd.DataFrame:
    """Per-column dtype, null share, cardinality and a value sample."""
    if not csv_path.exists():
        raise FileNotFoundError(f"No such file: {csv_path}")
    try:
        frame = pd.read_csv(csv_path, dtype=str)      # read raw: infer nothing
    except Exception as exc:
        raise RuntimeError(f"Could not read {csv_path}: {exc}") from exc

    rows = []
    for column in frame.columns:
        series = frame[column]
        numeric = pd.to_numeric(series, errors="coerce")
        rows.append({
            "column": column,
            "null_pct": round(series.isna().mean() * 100, 1),
            "distinct": series.nunique(dropna=True),
            "numeric_pct": round(numeric.notna().mean() * 100, 1),
            "sample": series.dropna().head(2).tolist(),
        })
    return pd.DataFrame(rows)

if __name__ == "__main__":
    print(profile(EXTRACT).to_string(index=False))
      column  null_pct  distinct  numeric_pct                     sample
  invoice_no       0.0      1204        100.0           [100417, 100418]
    supplier       0.4       311          0.0     [Acme Ltd, Beta Group]
  net_amount       0.0      1198         99.7        [1204.50, 98.00]
     vat_rate       2.1         3        100.0                [20.0, 5.0]
   issued_on       0.0       412          0.0   [2026-07-15, 15/07/2026]

Two problems are already visible. net_amount is 99.7% numeric, so a handful of rows carry something else. And issued_on mixes two date formats, which will produce NaT for one of them under any single-format parse — the situation handled in working with Excel dates and number formats.

Where the schema sits in a document pipeline Documents are extracted into a raw DataFrame. The schema gate validates it. Rows that pass continue to transformation and reporting. Rows that fail are written to a quarantine file with the reason attached, and a summary is logged. Without the gate, corrupted rows flow straight into the report, where the error is discovered by a human at the end. Extract PDF, Excel, OCR Schema gate dtypes, ranges, uniqueness, totals pass fail Transform types now guaranteed Report trustworthy figures Quarantine row + reason + source Alert counts and thresholds Without the gate, a shifted column reaches the report and a human finds it — or does not

Step 2: Declare a pandera Schema

Write the contract as a class. Each field carries a dtype and the checks that must hold, and the class-level configuration decides how strict the shape must be.

# pip install "pandera>=0.20,<0.23" "pandas>=2.2,<3"
import pandas as pd
import pandera.pandas as pa
from pandera.typing import Series

class InvoiceSchema(pa.DataFrameModel):
    """The contract every extracted invoice batch must satisfy."""

    invoice_no: Series[str] = pa.Field(str_matches=r"^\d{6}$", unique=True)
    supplier: Series[str] = pa.Field(nullable=False, str_length={"min_value": 2, "max_value": 120})
    net_amount: Series[float] = pa.Field(ge=0, le=1_000_000)
    vat_rate: Series[float] = pa.Field(isin=[0.0, 5.0, 20.0], nullable=True)
    issued_on: Series[pd.Timestamp] = pa.Field(
        ge=pd.Timestamp("2020-01-01"), le=pd.Timestamp("2030-01-01")
    )

    class Config:
        strict = True        # reject unexpected columns — a shifted extract shows up here
        coerce = True        # convert where safe, fail where not
        ordered = False

    @pa.dataframe_check
    def batch_total_is_positive(cls, frame: pd.DataFrame) -> bool:
        """A batch of invoices whose net total is zero or negative is not credible."""
        return frame["net_amount"].sum() > 0

    @pa.check("supplier")
    def no_placeholder_names(cls, series: Series[str]) -> Series[bool]:
        """Reject extraction artefacts that look like supplier names."""
        return ~series.str.strip().str.lower().isin({"unknown", "n/a", "-", "supplier"})

strict=True is the setting that earns its keep on extracted data. A column that appears because a PDF gained a header, or disappears because a template changed, is exactly the drift that silently misaligns downstream joins — and it fails here, by name, on the first run.

coerce=True converts a numeric string to a float and an ISO date string to a timestamp, but it does not invent values: a row whose amount is "1O4.5O" fails rather than becoming NaN. That distinction is what makes coercion safe to leave on.

Step 3: Validate and Split

Validate with lazy=True so the report lists every violation rather than stopping at the first, then split the frame instead of aborting.

# pip install "pandera>=0.20,<0.23" "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
import pandera.pandas as pa

def validate_split(frame: pd.DataFrame, schema: type[pa.DataFrameModel]
                   ) -> tuple[pd.DataFrame, pd.DataFrame]:
    """Return (valid rows, invalid rows with a reason column)."""
    try:
        return schema.validate(frame, lazy=True), pd.DataFrame()
    except pa.errors.SchemaErrors as exc:
        failures = exc.failure_cases                     # one row per violation
        bad_index = failures["index"].dropna().unique()

        reasons = (
            failures.dropna(subset=["index"])
            .groupby("index")
            .apply(lambda g: "; ".join(
                f"{row.column}: {row.check}" for row in g.itertuples()
            ), include_groups=False)
        )

        invalid = frame.loc[frame.index.isin(bad_index)].copy()
        invalid["_reason"] = invalid.index.map(reasons)
        valid = frame.loc[~frame.index.isin(bad_index)]

        # Column-level failures (missing column, wrong dtype) have no index and fail the batch
        structural = failures[failures["index"].isna()]
        if not structural.empty:
            raise ValueError(
                "Schema failed structurally: "
                + "; ".join(f"{r.column}: {r.check}" for r in structural.itertuples())
            ) from exc
        return valid, invalid

if __name__ == "__main__":
    raw = pd.read_csv(Path("data/extracted_invoices.csv"))
    good, bad = validate_split(raw, InvoiceSchema)
    print(f"{len(good):,} valid, {len(bad):,} quarantined")

Distinguishing row failures from structural failures is the design decision that makes this usable. One invoice with a bad amount is a data-quality event: quarantine it and carry on. A missing column is a contract breach: nothing downstream can be trusted, so the batch stops. Treating both the same way either drops a whole batch over one bad row, or reports a total from a frame that lost a column.

Step 4: Validate Records with pydantic

Where documents arrive one at a time — a filled form, a webhook, an OCR result per page — validate the record before it ever becomes a row.

# pip install "pydantic>=2.7,<3"
from datetime import date
from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, Field, ValidationError, field_validator

class Invoice(BaseModel):
    """One extracted invoice, validated and coerced at the boundary."""

    invoice_no: Annotated[str, Field(pattern=r"^\d{6}$")]
    supplier: Annotated[str, Field(min_length=2, max_length=120)]
    net_amount: Decimal = Field(ge=0, le=1_000_000, decimal_places=2)
    vat_rate: Decimal = Field(default=Decimal("20.0"))
    issued_on: date

    @field_validator("supplier")
    @classmethod
    def strip_and_reject_placeholders(cls, value: str) -> str:
        cleaned = " ".join(value.split())
        if cleaned.lower() in {"unknown", "n/a", "-"}:
            raise ValueError("placeholder supplier name")
        return cleaned

    @field_validator("net_amount", mode="before")
    @classmethod
    def clean_ocr_digits(cls, value):
        """Repair the two OCR confusions that are safe to repair."""
        if isinstance(value, str):
            return value.replace("O", "0").replace("l", "1").replace(",", "").strip("£ ")
        return value

if __name__ == "__main__":
    try:
        invoice = Invoice(invoice_no="100417", supplier="Acme Ltd",
                          net_amount="£1,2O4.50", vat_rate="20.0", issued_on="2026-07-15")
        print(invoice.net_amount)      # Decimal('1204.50')
    except ValidationError as exc:
        print(exc.errors()[0]["msg"])

Using Decimal rather than float for money is deliberate. Floating-point rounding on a few thousand invoices produces totals that differ by pennies from the source system, and a reconciliation that is out by pennies costs more time to investigate than it ever saves.

Repairing O to 0 inside a validator is a judgement call worth making explicitly: it is safe for a field constrained to digits and decimal points, and it would be reckless on a supplier name. Keep such repairs field-scoped and documented.

Edge Cases and Variants

Nullable Columns and Missing Data

nullable=True permits missing values; the check still applies to the values that are present. Combining nullability with a coverage requirement is usually what you actually want:

# pip install "pandera>=0.20,<0.23"
import pandera.pandas as pa
import pandas as pd

class Schema(pa.DataFrameModel):
    vat_rate: pa.typing.Series[float] = pa.Field(isin=[0.0, 5.0, 20.0], nullable=True)

    @pa.dataframe_check
    def vat_coverage(cls, frame: pd.DataFrame) -> bool:
        """At most 5% of rows may be missing a VAT rate."""
        return frame["vat_rate"].isna().mean() <= 0.05

Cross-Column Rules

Relationships between columns are dataframe checks, not field checks:

# pip install "pandera>=0.20,<0.23"
@pa.dataframe_check
def gross_matches_net_plus_vat(cls, frame: pd.DataFrame) -> pd.Series:
    """Row-wise: gross must equal net plus VAT, to the penny."""
    expected = (frame["net_amount"] * (1 + frame["vat_rate"] / 100)).round(2)
    return (frame["gross_amount"] - expected).abs() <= 0.01

Returning a boolean Series rather than a single bool makes it a row-level check, so the failing rows are identified individually and can be quarantined rather than failing the batch.

Schema Versioning

Extraction templates change. Keep the schema in version control beside the extractor, and record which version validated a batch:

# pip install "pandas>=2.2,<3"
SCHEMA_VERSION = "invoices-v3"

valid = valid.assign(_schema_version=SCHEMA_VERSION, _validated_at=pd.Timestamp.utcnow())

That one column answers "which rules did this row pass?" six months later, when the rules have moved on and someone is auditing an old report.

Row failures against structural failures Validation failures split into two classes. Row level failures such as a negative amount, an out of range date or a duplicate invoice number affect individual rows, which are quarantined while the batch continues. Structural failures such as a missing column, an unexpected extra column or a whole column of the wrong dtype invalidate every row, so the batch is stopped and nothing is published. Not every violation deserves the same response Row-level failures negative amount date outside the range duplicate invoice number placeholder supplier Quarantine the row batch continues, count is logged Structural failures column missing entirely unexpected extra column whole column wrong dtype batch total implausible Stop the batch nothing downstream can be trusted A shifted extract usually shows up on the right — which is why strict=True is worth the noise

Validation of the Validator

A schema that never fails is either perfect or wrong, and it is rarely perfect. Test it against deliberately broken data.

# pip install "pandera>=0.20,<0.23" "pandas>=2.2,<3" pytest
import pandas as pd
import pytest

GOOD = pd.DataFrame({
    "invoice_no": ["100417", "100418"],
    "supplier": ["Acme Ltd", "Beta Group"],
    "net_amount": [1204.50, 98.00],
    "vat_rate": [20.0, 5.0],
    "issued_on": pd.to_datetime(["2026-07-15", "2026-07-16"]),
})

def test_clean_batch_passes():
    valid, invalid = validate_split(GOOD, InvoiceSchema)
    assert len(valid) == 2 and invalid.empty

@pytest.mark.parametrize("column,value,expected_quarantined", [
    ("net_amount", -5.0, 1),          # negative amount
    ("vat_rate", 17.5, 1),            # rate not in the allowed set
    ("invoice_no", "10041", 1),       # wrong length
    ("supplier", "unknown", 1),       # placeholder name
])
def test_bad_row_is_quarantined(column, value, expected_quarantined):
    frame = GOOD.copy()
    frame.loc[0, column] = value
    valid, invalid = validate_split(frame, InvoiceSchema)
    assert len(invalid) == expected_quarantined
    assert len(valid) == len(GOOD) - expected_quarantined

def test_missing_column_stops_the_batch():
    with pytest.raises(ValueError, match="structurally"):
        validate_split(GOOD.drop(columns=["vat_rate"]), InvoiceSchema)

Parameterised negative tests are what stop a schema rotting. When a new field is added, the test list grows with it, and a rule that was accidentally weakened — ge=0 becoming ge=-1 during a debugging session — fails immediately.

Performance and Scale Notes

pandera validation is vectorised and costs roughly 10–20% of the time taken to read the data. On a million-row frame with a dozen checks, expect a second or two. Three habits keep that from growing.

Validate once, at the boundary, rather than after every transform. Re-validating a frame that has only been filtered wastes the whole cost for no new information.

Prefer built-in field checks (ge, isin, str_matches) over custom checks that use apply, which drops to Python-level iteration. A regular expression check on a million rows runs in milliseconds; the same rule written as a lambda over rows takes minutes.

For chunked ingestion, validate per chunk with the same schema object and accumulate the quarantine. That keeps peak memory flat and gives per-chunk failure counts — combine it with the streaming reader in handling large CSV files with chunking:

# pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23"
def validate_stream(csv_path, schema, chunk_rows: int = 200_000):
    """Validate a large file chunk by chunk, yielding valid frames."""
    quarantined = []
    for chunk in pd.read_csv(csv_path, chunksize=chunk_rows):
        valid, invalid = validate_split(chunk, schema)
        if not invalid.empty:
            quarantined.append(invalid)
        yield valid
    if quarantined:
        pd.concat(quarantined).to_csv("out/quarantine/stream.csv", index=False)
Cost of catching a bad row at each stage Four bars showing the relative effort to correct a bad value depending on where it is caught. At the schema gate it costs about one unit, a re-run. In the transform stage about four units, tracing back through joins. In the published report about twenty units, reissuing and notifying. After a downstream decision has been taken on it, about one hundred units. The schema gate is the cheapest place by an order of magnitude. Relative cost of fixing a bad row, by where it is caught schema gate 1x — re-run transform 4x — trace through joins published report 20x — reissue and notify decision taken 100x — the reason the gate sits immediately after extraction

Troubleshooting

SymptomRoot causeFix
SchemaError on the first row onlylazy=False, so validation stops at the first failurePass lazy=True and read failure_cases
Every row fails a dtype checkColumn read as object because of one bad valueSet coerce=True, or fix the read with an explicit dtype
strict=True fails on a harmless extra columnExtractor added a columnAdd it to the schema, or set strict="filter" to drop it
Dates fail the range checkExcel serials, not timestampsConvert serials before validating
Uniqueness check fails on a valid batchRows duplicated by a merge upstreamDeduplicate before validating, and assert the row count
Validation is slowCustom checks using applyRewrite as vectorised expressions or built-in checks
Quarantine grows every runThe schema encodes a rule the data never satisfiedRe-profile the data and correct the rule

Complete Working Script

#!/usr/bin/env python3
"""Validate an extracted dataset against a schema and quarantine what fails.

    pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23"
    python validate_batch.py data/extracted_invoices.csv --quarantine out/quarantine
"""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

import pandas as pd
import pandera.pandas as pa

MAX_QUARANTINE_PCT = 5.0

def load(csv_path: Path) -> pd.DataFrame:
    if not csv_path.exists():
        raise FileNotFoundError(f"No such file: {csv_path}")
    frame = pd.read_csv(csv_path, dtype={"invoice_no": str})
    if "issued_on" in frame.columns:
        frame["issued_on"] = pd.to_datetime(frame["issued_on"], errors="coerce", format="mixed")
    return frame

def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Schema-validate an extracted batch.")
    parser.add_argument("source", type=Path)
    parser.add_argument("--quarantine", type=Path, default=Path("out/quarantine"))
    parser.add_argument("--max-quarantine-pct", type=float, default=MAX_QUARANTINE_PCT)
    args = parser.parse_args(argv)

    try:
        raw = load(args.source)
        valid, invalid = validate_split(raw, InvoiceSchema)
    except (FileNotFoundError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2                                   # structural failure: stop the pipeline

    share = len(invalid) / max(len(raw), 1) * 100
    print(f"{len(valid):,} valid, {len(invalid):,} quarantined ({share:.2f}%)")

    if not invalid.empty:
        args.quarantine.mkdir(parents=True, exist_ok=True)
        target = args.quarantine / f"{args.source.stem}_rejects.csv"
        invalid.to_csv(target, index=False)
        print(f"rejects written to {target}")

    if share > args.max_quarantine_pct:
        print(f"error: quarantine rate {share:.2f}% exceeds {args.max_quarantine_pct}%",
              file=sys.stderr)
        return 1                                   # too much bad data to publish a report

    out = args.source.with_name(f"{args.source.stem}_validated.parquet")
    valid.to_parquet(out, index=False)
    print(f"wrote {out}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

The three distinct exit codes are the interface to whatever schedules this: 0 publishes, 1 means the data is too dirty to report on, and 2 means the extraction itself broke. Wiring those to different alerts is what turns validation into an operational control rather than a log line — see scheduling and logging automation jobs.

FAQ

pandera or pydantic — which should I use? pandera for tables, pydantic for records. A pipeline that extracts one document at a time and then aggregates typically uses pydantic at the extraction boundary and pandera once the records are a frame.

Does coerce=True hide problems? No, it converts where conversion is lossless and fails where it is not. "1204.50" becomes 1204.50; "1O4.5O" fails. What hides problems is errors="coerce" on a manual to_numeric, which turns unparseable values into nulls with no record of what they were.

How strict should strict be?strict=True on anything extracted from a document, because column drift is the most common silent corruption. strict="filter" when a source legitimately adds columns you do not consume.

What quarantine rate is acceptable? Set the threshold from the current baseline and tighten it. A batch that normally quarantines 0.1% and suddenly quarantines 4% has a new problem, even though 4% might be within an absolute limit — alert on the change as well as the level.

Can I generate a schema from existing data? Yes: pandera.infer_schema(frame) produces a starting point. Treat the output as a draft — it encodes the quirks of whatever sample it saw, including the ones you are trying to catch.

Living With a Schema

A schema is a contract with a maintenance cost, and the cost shows up in a predictable place: the day a legitimate business change makes valid data fail. A new VAT rate, a supplier whose name genuinely is one character long, an invoice number that switched from six digits to seven. Each looks like a bug in the pipeline and is not.

Budget for that. Keep the schema in the same repository as the extractor, review changes to it like code, and record why each rule exists — a one-line comment naming the incident that motivated a check is worth more than the check itself six months later. A rule nobody can explain is a rule the next person will delete.

Be equally willing to remove rules. A check that has never fired in a year is either protecting against something that cannot happen or is written so loosely that it never will; both are worth knowing. Reviewing the quarantine store quarterly and asking which rules actually caught something keeps the schema honest and stops it accumulating defensive clutter.

Finally, resist encoding business logic in the schema. Validation answers whether a row is structurally usable. Whether an invoice should have been raised at all is a different question, belongs downstream, and needs a different kind of report.

Part of Automating Document Data Pipelines.

Explore next