pandera: Validate pandas DataFrames in Python (2026)
Most data bugs do not crash anything. A CSV export quietly gains a duplicate ID, a price column arrives as text, or a new status value appears, and the numbers in your dashboard or model are wrong for weeks. pandera is an open-source library that lets you write a schema for a pandas DataFrame and check every column, type and business rule in one call. It gets more than 8 million downloads a month on PyPI. This tutorial was tested with pandera 0.34.1, pandas 3.1.0 and NumPy 2.5.4 on Python 3.13.5, and every output block below is the real output of the scripts shown.
Why pandera in 2026?
- Schemas are plain Python: columns, data types, value checks, uniqueness and nullability in one readable object.
- Type coercion: turn string columns from CSV files into real integers and floats while validating.
- Lazy validation collects every failure in a tidy DataFrame of failure cases, instead of stopping at the first bad value.
- Class-based
DataFrameModelschemas work like Pydantic models for tables, with inheritance and custom checks. - The
@pa.check_typesdecorator validates the inputs and outputs of your pipeline functions using normal type hints. - Beyond pandas, the same ideas work with Polars, PySpark, Ibis and other back ends through separate extras.
Row-by-row tools such as jsonschema are ideal for single JSON documents. pandera is built for whole tables, where you want vectorised checks over thousands or millions of rows.
Install
pip install "pandera[pandas]"
pandera 0.34 requires Python 3.10 or newer. Since recent releases the recommended import for pandas schemas is import pandera.pandas as pa; the old top-level import pandera as pa style is being phased out for pandas objects.
Step 1: A DataFrameSchema with lazy validation
The schema below describes an orders table. order_id must be a positive, unique integer; email must contain an at sign; amount must be between 0 and 10,000; status must be one of three values; and country is a two-letter code that may be missing. strict=True rejects unexpected columns, and coerce=True converts types before the checks run, so amounts stored as strings become floats.
import pandas as pd
import pandera.pandas as pa
from pandera.errors import SchemaErrors
schema = pa.DataFrameSchema(
{
"order_id": pa.Column(int, pa.Check.gt(0), unique=True),
"email": pa.Column(str, pa.Check.str_contains("@")),
"amount": pa.Column(float, pa.Check.in_range(0, 10_000)),
"status": pa.Column(str, pa.Check.isin(["paid", "pending", "refunded"])),
"country": pa.Column(str, pa.Check.str_length(2, 2), nullable=True),
},
strict=True,
coerce=True,
)
good = pd.DataFrame({
"order_id": [101, 102, 103],
"email": ["ana@shop.io", "bo@mail.com", "cy@site.org"],
"amount": ["19.99", "250", "1200.5"], # strings: coerce=True turns them into floats
"status": ["paid", "pending", "paid"],
"country": ["US", "PK", None],
})
clean = schema.validate(good)
print("good rows:", len(clean), "| amount dtype:", clean["amount"].dtype)
print("total amount:", clean["amount"].sum())
bad = pd.DataFrame({
"order_id": [201, 201, -5],
"email": ["dee@shop.io", "not-an-email", "ed@mail.com"],
"amount": [50.0, 12_500.0, 30.0],
"status": ["paid", "shipped", "pending"],
"country": ["DE", "USA", "FR"],
})
try:
schema.validate(bad, lazy=True)
except SchemaErrors as err:
fc = err.failure_cases
print("failures found:", len(fc))
for row in fc.itertuples():
print(f" {row.column:<9} {row.check:<38} index={row.index} value={row.failure_case}")
Real output (schema and failure cases)
Running python pandera_basics.py printed:
good rows: 3 | amount dtype: float64
total amount: 1470.49
failures found: 7
order_id field_uniqueness index=0 value=201
order_id field_uniqueness index=1 value=201
order_id greater_than(0) index=2 value=-5
email str_contains('@') index=1 value=not-an-email
amount in_range(0, 10000) index=1 value=12500.0
status isin(['paid', 'pending', 'refunded']) index=1 value=shipped
country str_length(2, 2) index=1 value=USA
The good table passes and comes back with amount converted to float64, so the total is computed correctly. The bad table has seven problems in five columns: a duplicated order ID (reported for both rows), a negative ID, a missing at sign, an amount over the limit, an unknown status and a three-letter country code. Because we passed lazy=True, pandera raised a single SchemaErrors exception whose failure_cases attribute is itself a DataFrame, with the column, the failed check, the row index and the bad value. You can log it, save it to a file or send it back to the data owner.
Step 2: Class-based models, custom checks and @check_types
For bigger projects, a DataFrameModel reads like a typed class. Each column is a Series[type] annotation with a Field for its rules, and a method decorated with @pa.dataframe_check adds a rule across columns. Here, revenue (units times price) must not exceed 50,000 per row. The Report model inherits all Sales columns and adds revenue, and @pa.check_types validates the function's input against Sales and its output against Report.
import pandas as pd
import pandera.pandas as pa
from pandera.typing import DataFrame, Series
from pandera.errors import SchemaError
class Sales(pa.DataFrameModel):
region: Series[str] = pa.Field(isin=["north", "south", "east", "west"])
units: Series[int] = pa.Field(ge=0)
price: Series[float] = pa.Field(gt=0)
@pa.dataframe_check
def revenue_under_limit(cls, df: pd.DataFrame) -> pd.Series:
return df["units"] * df["price"] <= 50_000
class Config:
coerce = True
class Report(Sales):
revenue: Series[float] = pa.Field(ge=0)
@pa.check_types
def add_revenue(df: DataFrame[Sales]) -> DataFrame[Report]:
return df.assign(revenue=df["units"] * df["price"])
sales = pd.DataFrame({
"region": ["north", "south", "east", "west"],
"units": [120, 40, 0, 75],
"price": [9.5, 120.0, 15.0, 22.0],
})
report = add_revenue(sales)
print(report.to_string(index=False))
print("total revenue:", report["revenue"].sum())
broken = sales.assign(region=["north", "mars", "east", "west"])
try:
add_revenue(broken)
except SchemaError as err:
case = err.failure_cases.iloc[0]
print(f"rejected: {err.check.name} at index {case['index']}")
too_big = sales.assign(units=[120, 40, 0, 5000])
try:
add_revenue(too_big)
except SchemaError as err:
case = err.failure_cases.iloc[0]
print(f"rejected: {err.check.name} at index {case['index']}")
print("Sales columns:", list(Sales.to_schema().columns))
Real output (models)
region units price revenue
north 120 9.5 1140.0
south 40 120.0 4800.0
east 0 15.0 0.0
west 75 22.0 1650.0
total revenue: 7590.0
rejected: isin at index 1
rejected: revenue_under_limit at index 3
Sales columns: ['region', 'units', 'price']
The clean sales table goes through add_revenue() and the returned report, with the new revenue column, is validated automatically: the total revenue is 7,590. When one region is mars, the input check fails on the isin rule at index 1 before the function body runs. When the west region sells 5,000 units, the custom revenue_under_limit check rejects row 3. Finally, to_schema() converts the model into a normal DataFrameSchema, which is handy for inspection or for exporting the schema.
Using pandera in a data pipeline
A common pattern is to validate at the edges: right after loading raw data, and right before writing results or training a model. That fits naturally into the data quality stage of an MLOps pipeline:
import pandas as pd
from pandera.errors import SchemaErrors
def load_orders(path):
raw = pd.read_csv(path)
try:
return schema.validate(raw, lazy=True)
except SchemaErrors as err:
err.failure_cases.to_csv("orders_failures.csv", index=False)
raise
Production tips
- Use
lazy=Truefor batch jobs so one run reports every problem; keep the default eager mode for fast fail in unit tests. - Turn on
coerce=Truewhen reading CSV or Excel files, where everything can arrive as text. - Use
strict=True(orstrict="filter"to drop extra columns) so schema drift is caught early. - Put the schemas in their own module and import them in both your pipeline and your tests.
- Decorate key transformation functions with
@pa.check_typesso the contract between steps is enforced automatically. - On very large tables, validate a sample during development and the full table in scheduled jobs.
Conclusion
pandera turns assumptions about your DataFrames into executable rules. With pandera 0.34.1 and pandas 3.1 on Python 3.13, you can coerce types, check values and uniqueness, collect every failure case in one report, and guard pipeline functions with typed DataFrameModel classes, so bad data is stopped before it reaches your reports or models.