Module 2: Declarative Data Quality Tests With Pandera

Validity declared, and how to read a complete failure report

Description

This lesson closes OrdersSchema with the third and last rule this module owns: Field(gt=0) over quantity, for validity. But this lesson's real center isn't that rule — you already saw, in lessons 5 and 6, how a new rule gets added to a class — it's something different: until now, every time a schema failed, you saw only one broken row at a time, because Pandera stops at the first rule that finds a problem. This lesson changes that behavior with lazy=True, and teaches you to read the complete report — SchemaErrors.failure_cases — with all three of S04's failures visible at once.

Connection to the module. This lesson finishes building OrdersSchema: the three rules — completeness (lesson 6), uniqueness (lesson 6), validity (this lesson) — run together, with lazy=True, for the first time. Lesson 8's project doesn't add any new rule — it takes exactly the schema this lesson leaves finished and runs it against the complete file, as the module's closing act.

An analogy: the full lab result, not a light that shuts off at the first out-of-range value

The project that closed this guide's module 1 already used the pre-op medical report image: a precise list of findings, instead of a vague suspicion. This lesson adds a nuance to that image. Imagine you ask a lab for a complete blood panel — ten different markers — and the lab's system was designed to stop the moment it finds the first out-of-range marker, without going on to check the other nine. You'd get back a paper that says "cholesterol: out of range" and nothing else — not a word about whether glucose, blood pressure, or any other marker also has a problem. You'd have to fix the cholesterol, order the test again, wait for the result, and only then find out about the second problem, if there is one.

That's, precisely, what you did in lessons 5 and 6 of this module: every time you ran .validate() with no additional parameter, Pandera behaved like that rushed lab — it stopped at the first rule that found a problem, and never got around to checking the others. lazy=True is asking the lab for the complete panel, all at once: all ten markers, each with its own result, on a single sheet of paper. This lesson builds that complete sheet — SchemaErrors.failure_cases — and teaches you to read it.

Worked example: first the third rule, isolated; then the complete schema with lazy=True

Step 1 — validity: quantity, with gt=0

Following lesson 6's same pattern — isolate the new rule before combining it —, here's quantity's validation alone:

# validity_check.py
import duckdb
import pandera.polars as pa

con = duckdb.connect("kiosko.duckdb")
df = con.sql("SELECT * FROM orders_s04").pl()

class ValiditySchema(pa.DataFrameModel):
    quantity: int = pa.Field(gt=0)

try:
    ValiditySchema.validate(df)
except pa.errors.SchemaError as exc:
    print(f"SchemaError: {exc}\n")
    print("failure_cases:")
    print(exc.failure_cases)

What to expect.

SchemaError: Column 'quantity' failed validator number 0: <Check greater_than: greater_than(0)> failure case examples: [{'quantity': -1}]

failure_cases:
shape: (1, 1)
┌──────────┐
│ quantity │
│ ---      │
│ i64      │
╞══════════╡
│ -1       │
└──────────┘

ORD-9507, caught — quantity=-1 doesn't pass gt=0, the same rule foundations M5's check_business_rules() already declared, now expressed as a Field parameter. Notice the error message this time is longer than nullable/unique's in lesson 6: it names the validator number (validator number 0) and describes the complete Check (<Check greater_than: greater_than(0)>) — gt=0 is, under the hood, exactly that Check object; the Field(gt=0) shortcut you use in code is a short way of writing Check.greater_than(0).

Step 2 — the complete schema, with lazy=True

Now combine the three rules — the ones lessons 6 and 7 built — into this module's final OrdersSchema, and run it with lazy=True:

# orders_schema_lazy.py
import duckdb
import pandera.polars as pa

con = duckdb.connect("kiosko.duckdb")
df = con.sql("SELECT * FROM orders_s04").pl()

class OrdersSchema(pa.DataFrameModel):
    order_id: str = pa.Field(unique=True)
    unit_price: float = pa.Field(nullable=False, ge=0)
    quantity: int = pa.Field(gt=0)

try:
    OrdersSchema.validate(df, lazy=True)
except pa.errors.SchemaErrors as exc:
    print(exc.failure_cases)

Two changes from everything you've run so far in this module: lazy=True as an argument to .validate(), and except pa.errors.SchemaErrors (with an s, plural) instead of SchemaError (singular). It isn't a minor detail — they're two different exception classes, and mixing them up is one of this lesson's common mistakes.

What to expect.

shape: (4, 6)
┌──────────────┬────────────────┬────────────┬──────────────────┬──────────────┬───────┐
│ failure_case ┆ schema_context ┆ column     ┆ check            ┆ check_number ┆ index │
│ ---          ┆ ---            ┆ ---        ┆ ---              ┆ ---          ┆ ---   │
│ str          ┆ str            ┆ str        ┆ str              ┆ i32          ┆ i32   │
╞══════════════╪════════════════╪════════════╪══════════════════╪══════════════╪═══════╡
│ ORD-9502     ┆ Column         ┆ order_id   ┆ field_uniqueness ┆ null         ┆ 1     │
│ ORD-9502     ┆ Column         ┆ order_id   ┆ field_uniqueness ┆ null         ┆ 9     │
│ null         ┆ Column         ┆ unit_price ┆ not_nullable     ┆ null         ┆ 2     │
│ -1           ┆ Column         ┆ quantity   ┆ greater_than(0)  ┆ 0            ┆ 6     │
└──────────────┴────────────────┴────────────┴──────────────────┴──────────────┴───────┘

Four rows, not one — this is the lab's complete sheet, with every out-of-range marker visible at once, with none of them hiding the result from another. Each column of this DataFrame answers a specific question:

ColumnWhat it answers
failure_caseThe exact value that broke the rule (ORD-9502, null, -1)
schema_contextThe schema level where it happened (Column, for this module's three rules)
columnWhich column failed (order_id, unit_price, quantity)
checkThe internal name of the rule that was violated (field_uniqueness, not_nullable, greater_than(0))
check_numberAn order number, only for columns with several numbered Checks (here, null for unique/nullable, which aren't numbered Checks but Field properties)
indexThe row's position within the DataFrame (starting at 0), not an order_id

Step 3 — translate index to order_id, for a readable report

index is a numeric position, not a business identifier — to know which order each row of the report corresponds to, you need to cross-reference it against the original df:

# orders_schema_lazy.py -- continued
import polars as pl

readable = exc.failure_cases.with_columns(
    pl.col("index").map_elements(lambda i: df["order_id"][i], return_dtype=pl.String).alias("order_id")
).select(["order_id", "column", "check", "failure_case"])

print(readable)

What to expect.

shape: (4, 4)
┌──────────┬────────────┬──────────────────┬──────────────┐
│ order_id ┆ column     ┆ check            ┆ failure_case │
│ ---      ┆ ---        ┆ ---              ┆ ---          │
│ str      ┆ str        ┆ str              ┆ str          │
╞══════════╪════════════╪══════════════════╪══════════════╡
│ ORD-9502 ┆ order_id   ┆ field_uniqueness ┆ ORD-9502     │
│ ORD-9502 ┆ order_id   ┆ field_uniqueness ┆ ORD-9502     │
│ ORD-9503 ┆ unit_price ┆ not_nullable     ┆ null         │
│ ORD-9507 ┆ quantity   ┆ greater_than(0)  ┆ -1           │
└──────────┴────────────┴──────────────────┴──────────────┘

Now the report reads at a glance, with no need to remember which row is in which position: ORD-9502 appears twice for field_uniqueness, ORD-9503 once for not_nullable, ORD-9507 once for greater_than(0). They're exactly the same three rows — ORD-9502 (two appearances), ORD-9503, ORD-9507 — that foundations' validate_orders() already identified in module 1, now confirmed with a completely different tool, declared instead of programmed step by step.

Diagram: eager stops at the first, lazy gathers them all

flowchart TB
    S["OrdersSchema.validate(df)"]
    S -->|"no lazy=True\n(eager, by default)"| E["Stops at the\nFIRST broken rule\n(columns' declaration\norder)"]
    S -->|"lazy=True"| L["Checks EVERY rule,\nfor EVERY column,\nbefore reporting anything"]

    E --> ER["SchemaError\n1 problem"]
    L --> LR["SchemaErrors\nfailure_cases:\n4 rows, 3 dimensions"]

Going deeper: why "4 rows" and "3 dimensions" are both correct at once

It's worth precisely reconciling two numbers that might seem contradictory. failure_cases has 4 rows — because uniqueness, by design (lesson 6), reports both appearances of the duplicate pair as separate rows. But those 4 rows cover exactly 3 dimensions of data quality: completeness (ORD-9503), uniqueness (both ORD-9502 rows), validity (ORD-9507). There's no contradiction — they're two different questions about the same result: "how many physical rows have some problem?" (4) versus "how many distinct kinds of problem show up?" (3). This guide's own DESIGN uses the second count — "3 of the 6 dimensions, literally" — because it's the one that matters for the module's narrative thread: Pandera's OrdersSchema covers exactly the same three dimensions foundations' validate_orders() already covered — completeness, uniqueness, validity —, not one more.

The other three dimensions — consistency (ORD-9508, product_id="P099"), accuracy (ORD-9509, unit_price=60.00), freshness (the whole file, outside the 24-hour window) — stay exactly where module 1 left them: with no check catching them. OrdersSchema, as it stands at the end of this lesson, validates S04's complete DataFrame (with all six original columns, thanks to strict=False), but only has rules for three of those columns. If you ran OrdersSchema.validate() against ORD-9508 or ORD-9509 in isolation, they'd pass with no problem at all — not because the schema has a bug, but because, just like in lesson 5, it was never asked the right question. This guide's module 3 (consistency) and module 5 (accuracy) close those two questions with new tools, not by extending OrdersSchema with more Fields.

Common mistakes

Catching pa.errors.SchemaError (singular) when you used lazy=True. What happens: someone writes OrdersSchema.validate(df, lazy=True) inside a try/except pa.errors.SchemaError, and the program crashes with an uncaught exception, instead of entering the except block. Why it happens: SchemaError and SchemaErrors are two different classes — one letter "s" apart, easy to overlook —, and lazy=True always raises the plural version. How to spot it: if your traceback mentions pandera.errors.SchemaErrors (with an "s" at the end) but your code catches pandera.errors.SchemaError (without one), you have the wrong pair. How to fix it: memorize the exact rule — without lazy=True (or with lazy=False, the default), Pandera raises SchemaError on the first failure; with lazy=True, it raises SchemaErrors, with the complete report inside .failure_cases.

Treating index as if it were the order_id. What happens: someone reads failure_cases's index column (say, 6) and reports it directly as if it were a business identifier, instead of translating it against the original DataFrame. Why it happens: in a report with few rows, it's tempting to assume the number "obviously" corresponds to something recognizable. How to spot it: if your final report shows someone at Kiosko a number like 6 with no context, that person has no way of knowing it refers to ORD-9507index is a position within the DataFrame, starting at 0, not a business identifier. How to fix it: always translate index against the real identifier column — this lesson's map_elements, or a join if the row volume justifies it — before showing a report to someone who doesn't know the internal implementation.

Searching for the word "completeness" or "uniqueness" directly in the check column. What happens: someone writes a filter like failure_cases.filter(pl.col("check") == "completeness"), expecting the check column to use this guide's six-quality-dimension vocabulary, and the filter returns no rows. Why it happens: Pandera has its own internal vocabulary for naming checks — not_nullable, field_uniqueness, greater_than(0) —, which doesn't match word-for-word with "completeness"/"uniqueness"/"validity." How to spot it: if your filter on check never finds anything, check that column's real values with failure_cases["check"].unique() before assuming which exact text to search for. How to fix it: keep, in your own code, a small dictionary that translates Pandera's vocabulary into this guide's ({"not_nullable": "completeness", "field_uniqueness": "uniqueness", "greater_than(0)": "validity"}) — exactly the same kind of manual translation module 1's project's count_dimensions_touched() already did, now applied to a different tool's vocabulary.

Exercises

Exercise 1 — Add the checks-to-dimensions translation, and count how many distinct dimensions appear in the report. Using the dictionary suggested in this lesson's third "Common mistake," write code that counts how many distinct dimensions (not rows) appear in the worked example's failure_cases.

See solution
CHECK_TO_DIMENSION = {
    "not_nullable": "completeness",
    "field_uniqueness": "uniqueness",
    "greater_than(0)": "validity",
}

dimensions = {CHECK_TO_DIMENSION[check] for check in exc.failure_cases["check"].to_list()}
print(f"Distinct dimensions: {len(dimensions)} ({sorted(dimensions)})")

Expected output:

Distinct dimensions: 3 (['completeness', 'uniqueness', 'validity'])

Confirmed, with its own counting method: 4 rows, 3 dimensions — exactly the number this lesson's Going deeper section already previewed, and the same pattern module 1's project's count_dimensions_touched() used, now applied to Pandera's vocabulary instead of validate_orders()'s reasons text.

Exercise 2 — Predict the result of running OrdersSchema against ORD-9508 alone, and confirm it. Build a single-row DataFrame — ORD-9508's data (product_id="P099", the rest of the fields per module 1 lesson 6's original CSV) — and run OrdersSchema.validate() against it. Does it pass or fail?

See solution
import polars as pl
from datetime import datetime

single_row = pl.DataFrame({
    "order_id": ["ORD-9508"],
    "unit_price": [1.00],
    "quantity": [2],
})

validated = OrdersSchema.validate(single_row)
print(f"Validated rows: {validated.height}")

Expected output:

Validated rows: 1

It passes, with no error. ORD-9508 has a unique order_id (in this single-row DataFrame), a present, non-negative unit_price=1.00, a positive quantity=2 — the three rules OrdersSchema declares all hold. That row's real problem — product_id="P099" doesn't exist in Kiosko's catalog — is consistency, a dimension OrdersSchema never promised to cover. This exercise confirms, with an isolated row and real evidence, the same Going deeper conclusion: this module's schema has no bug letting this row through, it simply isn't asked the right question.

Exercise 3 — Explain why check_number is null for not_nullable and field_uniqueness, but 0 for greater_than(0). Based on this lesson's worked example, in 2-3 sentences, explain that difference.

See solution

nullable and unique are properties of the Field itself — boolean flags Pandera checks directly, without going through that column's list of Checks —, so they don't have a "check number" within a sequence. gt=0, on the other hand, is a shortcut for Check.greater_than(0), a real object that lives inside a list of checks associated with the quantity column — a column could have several chained Checks, for example Field(gt=0, le=100) —, and check_number identifies which one in that list was the one that failed, starting at 0. The distinction reflects how Pandera is built internally: nullable/unique/coerce are Field attributes; numeric bounds (gt, ge, lt, le, eq, ne, in_range, isin, str_*) are all numbered Checks.

Summary and next step

In this lesson you closed OrdersSchema with the third rule — Field(gt=0) over quantity, for validity — and learned to read a run's complete report, with lazy=True and SchemaErrors.failure_cases, instead of one failure at a time. You reconciled two numbers that seemed contradictory — 4 rows, 3 dimensions — and translated the raw report (numeric positions, internal check vocabulary) into something readable for anyone at Kiosko, by cross-referencing it against order_id.

Before moving on you should be able to: explain the difference between SchemaError and SchemaErrors, and when each one shows up; read every column of failure_cases from memory; and explain why OrdersSchema, as it stands at the end of this lesson, lets ORD-9508 and ORD-9509 through with no error at all, without that being a flaw in the schema.

You have the complete OrdersSchema, with all three rules, actually run, with the readable report built. Lesson 8 — the project that closes this module — takes exactly this schema and runs it against S04's complete file, end to end, confirming it catches the same three rows validate_orders() already caught in module 1 — the same promise this module's lesson 1 opened with, now fulfilled with executed evidence.

Resources

  • Pandera — official documentation, lazy validation and SchemaErrors section (failure_cases, its columns, and the difference from SchemaError). pandera.readthedocs.io. In English.
  • Pandera — official repository (GitHub, unionai-oss/pandera), Check source code and its shortcuts (gt, ge, lt, le) inside Field. github.com/unionai-oss/pandera. In English.
  • Module 1, project (lesson 8), of this same guide — the source of count_dimensions_touched(), the manual translation pattern this lesson's Exercise 1 reuses with Pandera's vocabulary. src/guides/data-reliability-and-governance-guide/workbook/module-01-when-green-does-not-mean-correct/es/08-project-diagnosing-s04s-silent-failures.md. In Spanish.
  • This guide's DESIGN — the exact confirmation that OrdersSchema covers "3 of the 6 dimensions," the number this lesson's Going deeper section reconciles. src/guides/data-reliability-and-governance-guide/DISENO.md. In Spanish.