Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Analysis with Python (pandas, polars, DuckDB)  Module 15 – Data-quality checks you can automate

Schema validation and tests: pandera and pytest

Declare a pandera schema for a pandas DataFrame, inspect eligible lazy-validation failures, and test cleaning code with pytest and frame-comparison helpers.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, pandas 3.0.6, pandera 0.34.1, pytest 9.1.1, hypothesis 6.169.0, polars 1.44.2 and duckdb 1.5.6
  • By MySmartCoPilot
Free core, Pro depth: 9 of 30 minutes free
Free: A schema names columns, types and required value rules · Tests use chosen inputs and validation inspects each run · Choose the boundary and report needed for each rule. With a pass: 3 in-depth parts and 3 interview answers, about 21 minutes.

What you will learn

  • Declare a DataFrame schema with column types and checks in pandera
  • Report eligible lazy-validation failures and identify parsing or coercion limits
  • Test analysis functions with pytest and the pandas and polars testing helpers
  • Choose between pandera, hand-written checks and database constraints for each rule

Before you start

A six-column payment schema finds an old city name and a negative amount. Compare validation with tests and fixtures.

A schema names columns, types and required value rules

Schema validation checks a whole DataFrame against a written contract: which columns it has, the type of each, whether values may be missing, and which values and ranges are allowed. The pandera library lets you write that contract once in Python and can report eligible failures from its declared checks. Tests check the code instead of the data: pytest runs your cleaning functions on inputs you chose and fails when a result changes. You need both, because they catch different things. A test checks the code on the cases it actually executes; a schema checks the incoming data against its declared rules whenever validation is invoked. Neither establishes every possible property of arbitrary code and data. This lesson writes a pandera schema for an invented table of fee payments, validates a file eagerly and lazily, tests a cleaning step with pytest, and shows where each kind of rule belongs.

A schema boundary: The point where a declared table contract is actually validated, such as incoming data or an output handed to a consumer. is where the declared contract is invoked. In pandera 0.34 you import the pandas API as import pandera.pandas as pa and describe each column with pa.Column: its dtype, a list of checks, and options such as unique=True. Columns are not nullable unless you say so, and strict=True makes a column that the schema does not name an error as well. The schema then validates any DataFrame:

One schema, an eager and a lazy validation

validate_fees.py

"""The same file validated twice: eagerly, which stops at the first problem, and lazily, which collects them all."""
import pandas as pd
import pandera.pandas as pa

from fee_schema import fee_schema

fees = pd.read_csv("fees.csv", parse_dates=["paid_on"])
try:
    fee_schema.validate(fees)
except pa.errors.SchemaError as err:
    print("eager:", err)

try:
    fee_schema.validate(fees, lazy=True)
except pa.errors.SchemaErrors as err:
    cases = err.failure_cases
    print(f"\nlazy: {len(cases)} failure cases")
    print(cases[["column", "check", "failure_case", "index"]].to_string(index=False))

Output

eager: Column 'city' failed element-wise validator number 0: isin(['Chennai', 'Jaipur', 'Kochi', 'Mumbai', 'Pune']) failure cases: Bombay

lazy: 2 failure cases
column                                                  check failure_case  index
  city isin(['Chennai', 'Jaipur', 'Kochi', 'Mumbai', 'Pune'])       Bombay      2
amount                                    in_range(1, 200000)        -4500      4

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 validate_fees.py

fee_schema.py

"""The contract of the fee-payments table, written once and used by every step that reads or writes the table."""
import pandera.pandas as pa

CITIES = ["Chennai", "Jaipur", "Kochi", "Mumbai", "Pune"]
MODES = ["UPI", "card", "netbanking", "cash"]

fee_schema = pa.DataFrameSchema(
    {
        "payment_id": pa.Column(str, pa.Check.str_matches(r"^P\d{5}$"), unique=True),
        "student_id": pa.Column(str),
        "city": pa.Column(str, pa.Check.isin(CITIES)),
        "amount": pa.Column(int, pa.Check.in_range(1, 200_000)),
        "paid_on": pa.Column("datetime64[us]"),
        "mode": pa.Column(str, pa.Check.isin(MODES)),
    },
    strict=True,  # a column the schema does not name is an error too
)

fees.csv

payment_id,student_id,city,amount,paid_on,mode
P10001,S2041,Pune,18500,2026-07-03,UPI
P10002,S2042,Chennai,18500,2026-07-03,card
P10003,S2043,Bombay,9250,2026-07-04,UPI
P10004,S2044,Jaipur,18500,2026-07-05,netbanking
P10005,S2045,Pune,-4500,2026-07-05,UPI
P10006,S2046,Kochi,18500,2026-07-06,cash
P10007,S2047,Mumbai,9250,2026-07-06,card
P10008,S2048,Chennai,18500,2026-07-07,UPI

Eager validation, the default, stops at the first problem and raises a SchemaError: here the old city name Bombay in the third row. lazy validation: Validation that aggregates eligible failures into a report, subject to parsing, coercion and type prerequisites for later checks., validate(df, lazy=True), aggregates eligible failures into SchemaErrors. Coercion or incompatible types can prevent intended later checks; its failure_cases table reports the failures actually collected: the city and also the negative amount of 4,500, a refund typed into the payments column. Use eager validation while exploring, when the first error is all you need. Use lazy validation to collect eligible problems in one run, and inspect parse or coercion failures before relying on later value-check coverage. The resulting failure table can be counted, saved and sent to whoever owns the source.

Validate where data crosses a boundary: right after a file is read, so that a broken export is reported against its source, and right before a table is written or handed on, so that no later step receives a DataFrame that breaks the contract. Keep the schema in one module that every step imports, as fee_schema.py is here, so the rules live in one place.

Retain the original six-column schema while comparing eager and lazy outcomes on its actual file.

Interactive · stage by stage A schema names columns, types and required value rules Retain the original six-column schema while comparing eager and lazy outcomes on its actual file.
A schema names columns, types and required value rules, step 3 of 3. Showing Original fee schema, Eager result, Lazy result. In focus: Lazy result.Original fee schemaSix named columnsEager resultBombay fails firstLazy resultTwo failure cases

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. The original fee schema declares payment/student/city/amount/date/mode fields, their exact types and checks.
  2. The recorded eager validation stops on Bombay in the original third row.
  3. The recorded lazy result lists that city and the negative amount. Coercion/type failures in other inputs can prevent intended later value checks.

Checkpoint

Check: A schema names columns, types and required value rules

  1. Question 1 of 2 What does strict=True mean in this schema?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Unexpected columns are rejected

    Strictness constrains the named structure of the frame. Parsing and cleaning are separate operations from declared structure validation.

  2. Question 2 of 2 The original simple lazy validation reports two failure cases. How many is that?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 2 cases

    The recorded city and amount failures are two cases.

Tests use chosen inputs and validation inspects each run

A pytest test is a function whose name starts with test_, in a file whose name starts with test_; pytest finds them, runs them and reports each one. Inside, a plain assert states what must hold, and pandas.testing.assert_frame_equal compares two DataFrames value by value, including dtypes, column names and the index, which is what you want for the output of a cleaning step:

Three tests of a cleaning step, one of them failing

run_tests.py

"""Runs the tests with pytest and prints one line per test (pytest's own summary also prints the time taken)."""
import sys

import pytest


class OneLinePerTest:
    def pytest_runtest_logreport(self, report):
        if report.when == "call" or report.failed:
            print(f"{report.nodeid}: {report.outcome}")
            if report.failed:
                print("   ", report.longrepr.reprcrash.message.splitlines()[0])


code = pytest.main(["-p", "no:terminal", "-p", "no:cacheprovider", "test_clean_fees.py"], plugins=[OneLinePerTest()])
print("pytest exit status:", int(code))
sys.exit(int(code))

Output (exit status 1)

test_clean_fees.py::test_old_city_names_become_current_ones: passed
test_clean_fees.py::test_other_columns_are_untouched: passed
test_clean_fees.py::test_cleaned_file_matches_the_schema: failed
    Failed: 1 failure case(s): amount in_range(1, 200000): -4500
pytest exit status: 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 run_tests.py

test_clean_fees.py

import pandas as pd
import pandas.testing as pdt
import pandera.pandas as pa
import pytest

from clean_fees import clean_fees
from fee_schema import fee_schema


def test_old_city_names_become_current_ones():
    raw = pd.DataFrame({"city": [" Bombay", "Cochin ", "Pune"]})
    expected = pd.DataFrame({"city": ["Mumbai", "Kochi", "Pune"]})
    pdt.assert_frame_equal(clean_fees(raw), expected)


def test_other_columns_are_untouched():
    raw = pd.read_csv("fees.csv", parse_dates=["paid_on"])
    out = clean_fees(raw)
    pdt.assert_frame_equal(out.drop(columns="city"), raw.drop(columns="city"))


def test_cleaned_file_matches_the_schema():
    out = clean_fees(pd.read_csv("fees.csv", parse_dates=["paid_on"]))
    try:
        fee_schema.validate(out, lazy=True)
    except pa.errors.SchemaErrors as err:
        found = [f"{c.column} {c.check}: {c.failure_case}" for c in err.failure_cases.itertuples()]
        pytest.fail(f"{len(found)} failure case(s): " + "; ".join(found))

clean_fees.py

"""A cleaning step of the fee pipeline: today's export writes some cities by their old names."""
OLD_NAMES = {"Bombay": "Mumbai", "Cochin": "Kochi"}


def clean_fees(raw):
    out = raw.copy()
    out["city"] = out["city"].str.strip().replace(OLD_NAMES)
    return out

The first two tests pass: clean_fees() maps old city names to current ones and leaves the other columns alone. The third test runs the schema on the cleaned file and fails, because cleaning fixed the city but not the refund row. That is the point of putting a schema into a test: the test suite now knows the contract too. In a project you would simply run pytest; the small runner prints one line per test instead, because pytest’s own summary also prints how long the run took, which changes every time.

pytest checks the cleaning code on chosen fixture files; on every run a pandera schema checks the data the code produced.Cleaning codeclean_fees()pytest testsfixture files you chose,run before the code shipsToday's exportdata you did not chooseThe pipeline runs the codepandera schemavalidate(lazy=True)Every row passes:loadFailure cases:stop, or set rows asidechecked bypassfail

Tests check the code, schemas check the data

Text description of the diagram

The cleaning code, such as clean_fees(), is checked by pytest tests that run it on fixture files you chose, before the code ships.

  • On every run, the pipeline runs the same code on today's export, data that nobody chose in advance.
  • The output goes through the pandera schema with validate(lazy=True).
  • If every row passes, the data is loaded.
  • If there are failure cases, the run stops or sets the failing rows aside.

Keep the two passes and real negative-payment failure rather than rewriting the original test result.

Interactive · stage by stage Tests use chosen inputs and validation inspects each run Keep the two passes and real negative-payment failure rather than rewriting the original test result.
Tests use chosen inputs and validation inspects each run, step 3 of 3. Showing Original city test, Original preservation, Original schema test. In focus: Original schema test.Original city testOld names mappedOriginal preservationOther columns unchangedOriginal schema testNegative payment remains

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. The first original test checks the exact old-name mapping on its chosen input.
  2. The second original test checks that cleaning leaves the other columns intact.
  3. The third original test fails because city cleaning does not fix the negative payment. That genuine failure is part of the unchanged teaching transcript.

Checkpoint

Check: Tests use chosen inputs and validation inspects each run

  1. Question 1 of 2 Can a green fixture test prove every future input is correct?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, it establishes the tested contract on chosen cases

    Test coverage depends on the selected examples and properties. Unexpected input shapes or values can still lie outside that fixture.

  2. Question 2 of 2 Two of the original three tests pass. How many fail?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1 tests

    Three total tests minus two passes leaves one failure.

Choose the boundary and report needed for each rule

The same rule can live in three places, and each place has strengths:

Item Details
Runs on pandera schema: A DataFrame, at any step of the pipeline; Hand-written checks: A DataFrame, wherever you call the code; Database constraints: Every insert and update in the database
Reports pandera schema: Eligible failures collected with lazy=True; parsing can limit later checks; Hand-written checks: Whatever you return, such as a results table; Database constraints: An error that rejects the statement
Suits pandera schema: Types, missing values, ranges, row rules and custom whole-DataFrame checks; Hand-written checks: Custom rules, including shares, trends and freshness; Database constraints: Rules that must never break: keys, required values

Schema validation and hand-written checks express whole-table rules. Choose by required inputs, reporting and boundaries; constraints guard stored-data rules. The parts below cover what a first schema usually gets wrong: coercion converts types but does not clean text, and quarantine sets failing rows aside while the rest load. For tests, test fixture: A deliberately selected input and expected behaviour used by a test to evaluate a stated code contract. are small inputs saved next to the tests, and property-based testing with Hypothesis generates inputs automatically and can shrink an observed failure to a simpler case under its strategy and settings.

Compare the required boundary and failure report across validation layers.

Interactive · stage by stage Choose the boundary and report needed for each rule Use the required boundary and error report to choose between the three original validation layers.
Choose the boundary and report needed for each rule, step 3 of 3. Showing DataFrame schema, Hand-written suite, Database constraints. In focus: Database constraints.DataFrame schemaStructure and value rulesHand-written suiteCustom result rowsDatabase constraintsStored-row guards

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. The schema can inspect columns, row values and whole-frame properties using its declared check mechanisms.
  2. A custom suite can combine histories, reference data and specific reporting policies.
  3. Constraints reject invalid writes to stored data under the database's supported rules. These layers can overlap rather than forming exclusive categories.

Checkpoint

Check: Choose the boundary and report needed for each rule

  1. Question 1 of 2 Can a whole-frame schema check express a missing-value share?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A supported whole-frame check can express that expectation

    The check mechanism can evaluate a frame-level property under the declared version. An exclusive row-only view omits whole-frame check capabilities.

  2. Question 2 of 2 The original comparison lists schema, hand-written suite and constraints. How many layers is that?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 3 layers

    Those are three named validation layers.

Schemas for real exports: three traps, parsing first, and a failure report Pro: opens with a Pro, Premium or Ultimate pass

Real exports break schemas in ways a tidy example never does. This part reproduces three traps of pandas 3 and pandera, runs a month’s export with seven planted problems through one lazy validation, and turns the failure cases into a report.

In this part:

  • Nullability, text dtype and parsing are separate choices
  • Parsing enables the intended value checks to run

2 diagrams · 2 runnable examples (Python, export_report.py, export_schema.py, fees_export.csv) · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Testing analysis code: fixture files, two versions and generated inputs Pro: opens with a Pro, Premium or Ultimate pass

A test suite is only as good as the inputs it tries. This part tests a loading step with three saved fixture files, catches a bug that only an empty file shows, checks a polars port against pandas, and lets Hypothesis find the simplest input that breaks a calculation.

In this part:

  • An empty fixture preserves the declared output types
  • A port comparison preserves values, order and types
  • Generated tests search a declared input space

3 diagrams · 2 runnable examples (clean_orders.py, run_order_tests.py, run_property_tests.py, shares.py, test_clean_orders.py, test_shares.py) · about 8 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Schema, hand-written check or database constraint: a measured choice Pro: opens with a Pro, Premium or Ultimate pass

Where a rule lives decides what happens when it breaks and what it costs. This part sends the same bad batch through DuckDB constraints and through a pandera schema with quarantine, measures each kind of check on a million rows, and ends with a rule-by-rule table.

In this part:

  • A failed batch and quarantined rows are different outcomes
  • Measure the original validation work before choosing a layer

2 diagrams · 2 runnable examples (Python) · about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise the original quality contracts

Practice that adapts to you

Practise this lesson

6 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 A schema names six columns and receives a seventh unexpected column under strict=True. How many columns are unexpected?

    Type a number.

    Show the answer: Question 1 of 6

    Answer: 1 columns

    Only the additional seventh column is outside the named schema.

  2. Question 2 of 6 A suite has eight tests and seven pass. How many fail?

    Type a number.

    Show the answer: Question 2 of 6

    Answer: 1 tests

    One of the eight tests fails.

  3. Question 3 of 6 A rule is enforced at input, output and stored-table boundaries. How many placements is that?

    Type a number.

    Show the answer: Question 3 of 6

    Answer: 3 placements

    The three named boundaries each enforce it.

  4. Question 4 of 6 A file has an unknown city in row 3 and a negative amount in row 5. What does fee_schema.validate(df) report without lazy=True?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: Only the first problem it meets, as a SchemaError

    Eager validation stops at the first eligible failure and raises SchemaError. Lazy validation can aggregate eligible failures, but coercion or incompatible types can prevent intended later checks. Use the actual original failure-case examples to assess coverage.

  5. Question 5 of 6 A column is declared pa.Column(float, pa.Check.gt(0)) and one value is missing. What happens?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: The schema fails with not_nullable, because columns are not nullable unless you say so

    pandera columns have nullable=False by default, so a missing value is a failure case of its own. Checks such as gt(0) skip missing values, so if you set nullable=True, a missing amount passes without any report.

  6. Question 6 of 6 Which new-input changes do the stated input-schema rules directly identify, while preselected code fixtures need not contain those new cases?

    Choose every answer that is right.

    Show the answer: Question 6 of 6

    Answer:

    • A new export that suddenly contains negative amounts
    • A vendor that renames a column without warning

    The declared incoming-data rules check negative amounts and required column names in each validated input. A suitable code fixture can expose a mapping or function bug. Neither approach alone establishes every possible property of arbitrary data and code.

Interview questions

  1. Fresher to mid · Warm-up

    What does lazy=True change when you validate a DataFrame with pandera?

    By default pandera validates eagerly: it stops at the first failed check and raises a SchemaError that names that one problem. With validate(df, lazy=True), it collects eligible failures into SchemaErrors and its failure_cases table. Incompatible types or failed coercion can prevent later value checks, so inspect those failures before using row indices to split a batch. The collected table can be counted, saved and sent to the source owner. Lazy validation is useful for pipelines when its coverage and failure policies are explicit. Eager validation is fine while exploring, when the first error is all you need. Lazy validation does not fix anything; it only reports more.

  2. Mid to senior

    A cleaning function has 30 passing unit tests, yet last week it turned 2 % of amounts into missing values in production, and nobody noticed for four days. What did the tests miss, and what would you add: more fixtures, a schema check at run time, or property-based tests?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A partner sends 2 million rows a day, and about 0.3 % fail your pandera schema. Product wants the load to go on. Design what happens to the failing rows, who sees them, and when the load should stop instead.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    Your analysts maintain pandera schemas for tables that also live in a database. A senior engineer says the rules belong in the database as constraints instead. When is each right, and what do you do with rules that fit neither?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A pandera schema states each column’s dtype, checks and options once; strict=True also rejects unexpected columns, and columns are not nullable unless you say so.
  • Eager validation stops at the first problem; lazy=True collects eligible failures in failure_cases, while parse and coercion failures can limit later checks.
  • pytest finds test_ functions in test_ files; assert_frame_equal compares values, dtypes, columns and index.
  • Tests check the code on inputs you chose; schemas check the data on every run. A schema inside a test brings the two together.

Terms in this lesson

generated property test
A test that evaluates a stated property on generated examples from a declared strategy and settings, with finite search coverage.
lazy validation
Validation that aggregates eligible failures into a report, subject to parsing, coercion and type prerequisites for later checks.
schema boundary
The point where a declared table contract is actually validated, such as incoming data or an output handed to a consumer.
test fixture
A deliberately selected input and expected behaviour used by a test to evaluate a stated code contract.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.