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.
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
What you’ll learn · Before you start
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
On this page
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:
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.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original fee schema declares payment/student/city/amount/date/mode fields, their exact types and checks.
- The recorded eager validation stops on Bombay in the original third row.
- 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
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:
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.
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.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The first original test checks the exact old-name mapping on its chosen input.
- The second original test checks that cleaning leaves the other columns intact.
- 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
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.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The schema can inspect columns, row values and whole-frame properties using its declared check mechanisms.
- A custom suite can combine histories, reference data and specific reporting policies.
- 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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Interview questions
-
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.
-
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?
-
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.
-
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?
Key takeaways
- A pandera schema states each column’s dtype, checks and options once;
strict=Truealso rejects unexpected columns, and columns are not nullable unless you say so. - Eager validation stops at the first problem;
lazy=Truecollects eligible failures infailure_cases, while parse and coercion failures can limit later checks. - pytest finds
test_functions intest_files;assert_frame_equalcompares 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
- pandera documentation (Pandera developers)
- pandera: Error reports and lazy validation (Pandera developers)
- pandera: DataFrame schemas (Pandera developers)
- pandera: Checks (Pandera developers)
- pytest: How to invoke pytest (pytest developers)
- pytest: How to use fixtures (pytest developers)
- pytest: How to parametrize fixtures and test functions (pytest developers)
- pandas.testing.assert_frame_equal (pandas development team (NumFOCUS))
- polars.testing.assert_frame_equal (Polars)
- Hypothesis: API reference (settings) (Hypothesis developers)
- DuckDB: Constraints (DuckDB Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress