Validate DataFrame Schemas with Pandera
How to validate pandas and Polars DataFrame schemas with Pandera, covering column types, constraints, custom checks, hypothesis testing, and schema inheritance.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Pandera is a data validation library for pandas and Polars DataFrames. You define a schema that specifies column names, data types, and constraints (value ranges, nullability, uniqueness). Pandera validates the DataFrame against the schema and raises informative errors when data doesn’t match. This catches data quality issues early in pipelines — before bad data reaches downstream consumers or production models.
When to Use
- ETL pipelines where upstream data quality is uncertain
- ML feature engineering — validate features before model training
- Data ingestion from external APIs, files, or databases
- Testing data transformations — assert output matches expected schema
- Any pipeline where silent data corruption causes downstream issues
When NOT to Use
- One-off exploratory analysis — just use
df.dtypesanddf.describe() - When you need full data profiling — use Great Expectations or ydata-profiling instead
- Real-time validation with strict latency requirements — Pandera adds overhead per validation
- When the schema changes frequently and maintenance cost is high
Solution
Basic schema validation
import pandas as pd
import pandera as pa
from pandera import Column, DataFrameSchema, Check
schema = DataFrameSchema({
"order_id": Column(int, checks=Check.gt(0)),
"customer_id": Column(int, nullable=False),
"order_date": Column(pa.DateTime),
"amount": Column(float, checks=[Check.ge(0), Check.le(100000)]),
"status": Column(str, checks=Check.isin(["pending", "completed", "cancelled"])),
})
df = pd.DataFrame({
"order_id": [1, 2, 3],
"customer_id": [101, 102, 103],
"order_date": pd.to_datetime(["2025-01-01", "2025-01-02", "2025-01-03"]),
"amount": [100.0, 250.0, 75.5],
"status": ["completed", "pending", "cancelled"],
})
# Validate — raises SchemaError if invalid
validated_df = schema.validate(df)
print("Validation passed!")
Schema with class-based syntax
from pandera import Field
from pandera.typing import Series
import pandera as pa
class OrderSchema(pa.DataFrameModel):
order_id: Series[int] = Field(gt=0, description="Unique order identifier")
customer_id: Series[int] = Field(nullable=False)
order_date: Series[pa.DateTime] = Field(le="2025-12-31")
amount: Series[float] = Field(ge=0, le=100000)
status: Series[str] = Field(isin=["pending", "completed", "cancelled"])
quantity: Series[int] = Field(ge=1, le=1000)
class Config:
strict = True # Reject extra columns
coerce = True # Auto-convert types
df = pd.DataFrame({
"order_id": [1, 2, 3],
"customer_id": [101, 102, 103],
"order_date": pd.to_datetime(["2025-01-01", "2025-01-02", "2025-01-03"]),
"amount": [100.0, 250.0, 75.5],
"status": ["completed", "pending", "cancelled"],
"quantity": [2, 1, 5],
})
validated = OrderSchema.validate(df)
Custom validation checks
import pandera as pa
from pandera import Column, Check, DataFrameSchema
def is_valid_email(series: pd.Series) -> pd.Series:
"""Check that all values match email pattern."""
import re
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return series.str.match(pattern)
schema = DataFrameSchema({
"email": Column(str, checks=Check(is_valid_email, element_wise=False)),
"age": Column(int, checks=[
Check.ge(18, error="Must be 18 or older"),
Check.le(120, error="Age must be realistic"),
]),
"phone": Column(str, checks=Check.str_matches(r'^\+?\d{10,15}$')),
})
Column-level checks
schema = DataFrameSchema({
"id": Column(int, checks=[
Check.unique(), # No duplicates
Check.gt(0), # Positive
]),
"name": Column(str, checks=[
Check.str_length(min_value=1, max_value=100),
Check.not_nullable(),
]),
"price": Column(float, checks=[
Check.ge(0),
Check.le(10000),
Check(lambda s: s.std() < 1000, element_wise=False, error="Price variance too high"),
]),
"category": Column(str, checks=[
Check.isin(["electronics", "books", "clothing", "food"]),
], nullable=True), # Can be null
})
DataFrame-level checks
schema = DataFrameSchema(
columns={
"start_date": Column(pa.DateTime),
"end_date": Column(pa.DateTime),
},
checks=Check(
lambda df: df["end_date"] > df["start_date"],
element_wise=False,
error="end_date must be after start_date",
)
)
Schema with coercion
schema = DataFrameSchema({
"order_id": Column(int, coerce=True),
"amount": Column(float, coerce=True),
"order_date": Column(pa.DateTime, coerce=True),
}, coerce=True) # Global coercion
# Pandera converts types before validating
df = pd.DataFrame({
"order_id": ["1", "2", "3"], # Strings → int
"amount": ["100.0", "250.0", "75.5"], # Strings → float
"order_date": ["2025-01-01", "2025-01-02", "2025-01-03"], # Strings → DateTime
})
validated = schema.validate(df)
print(validated.dtypes) # int64, float64, datetime64[ns]
Handling validation errors
try:
validated = schema.validate(df, lazy=True) # Collect all errors
except pa.SchemaErrors as e:
print(f"Found {len(e.failure_cases)} validation failures:")
print(e.failure_cases[["column", "check", "failure_case", "index"]])
# failure_cases is a DataFrame with details:
# column check failure_case index
# 0 amount greater_than(0) -50.0 5
# 1 status isin([...]) "unknown" 12
Schema inheritance
class BaseOrderSchema(pa.DataFrameModel):
order_id: Series[int] = Field(gt=0)
customer_id: Series[int] = Field(nullable=False)
amount: Series[float] = Field(ge=0)
class ExtendedOrderSchema(BaseOrderSchema):
status: Series[str] = Field(isin=["pending", "completed", "cancelled"])
shipping_address: Series[str] = Field(nullable=True)
class Config:
strict = True
coerce = True
Validating Polars DataFrames
import polars as pl
import pandera.polars as pa_pl
from pandera.typing.polars import Series
class OrderSchema(pa_pl.DataFrameModel):
order_id: Series[int] = Field(gt=0)
customer_id: Series[int] = Field(nullable=False)
amount: Series[float] = Field(ge=0, le=100000)
status: Series[str] = Field(isin=["pending", "completed", "cancelled"])
df = pl.DataFrame({
"order_id": [1, 2, 3],
"customer_id": [101, 102, 103],
"amount": [100.0, 250.0, 75.5],
"status": ["completed", "pending", "cancelled"],
})
validated = OrderSchema.validate(df)
Using schema in a pipeline
def process_orders(df: pd.DataFrame) -> pd.DataFrame:
"""Pipeline with validation at each stage."""
# Validate input
input_schema = DataFrameSchema({
"order_id": Column(int, checks=Check.gt(0)),
"amount": Column(float, checks=Check.ge(0)),
})
df = input_schema.validate(df)
# Transform
df["amount_with_tax"] = df["amount"] * 1.1
# Validate output
output_schema = DataFrameSchema({
"order_id": Column(int, checks=Check.gt(0)),
"amount": Column(float, checks=Check.ge(0)),
"amount_with_tax": Column(float, checks=Check.ge(0)),
})
return output_schema.validate(df)
Variants
Hypothesis testing integration
from pandera import Check
import pandera as pa
schema = DataFrameSchema({
"amount": Column(float, checks=[
Check.in_range(min_value=0, max_value=10000),
# Statistical check: mean should be around 500
Check(lambda s: abs(s.mean() - 500) < 100, element_wise=False),
# Standard deviation check
Check(lambda s: s.std() < 500, element_wise=False),
]),
})
Schema from existing DataFrame
import pandera as pa
# Infer schema from a DataFrame
df = pd.read_csv("data/orders.csv")
schema = pa.infer_schema(df)
print(schema)
# Save schema for reuse
schema.to_yaml("schemas/orders_schema.yaml")
# Load later
schema = pa.DataFrameSchema.from_yaml("schemas/orders_schema.yaml")
Decorator-based validation
from pandera import check_input, check_output
@check_input(OrderSchema)
@check_output(ExtendedOrderSchema)
def enrich_orders(df: pd.DataFrame) -> pd.DataFrame:
df["status"] = df["status"].fillna("pending")
df["shipping_address"] = df.get("shipping_address", "N/A")
return df
Best Practices
-
For a deeper guide, see Schedule and Monitor DAGs with Apache Airflow.
-
Use
lazy=Trueto collect all errors at once — default mode stops at the first error -
Use
coerce=Truewhen data comes from CSV (strings) and needs type conversion -
Set
strict=Trueto reject unexpected columns — catches schema drift -
Define schemas as classes (
pa.DataFrameModel) for readability and reuse -
Validate at pipeline boundaries — input and output of each stage
-
Use
nullable=Truefor optional columns — default is non-nullable -
Save schemas as YAML — enables schema sharing between teams
-
Use custom checks for business logic — built-in checks cover ranges and types
Common Mistakes
- Not using
lazy=True: default stops at the first error. You miss other issues in the same run. - Forgetting
coerce=True: CSV data comes as strings. Without coercion, type checks fail. - Not setting
strict=True: extra columns pass silently. Use strict mode to catch schema drift. - Validating only at the end: errors propagate through the pipeline. Validate at each stage boundary.
- Using element-wise checks for aggregate validations: use
element_wise=Falsefor checks that operate on the whole series (mean, std, count).
FAQ
What is the difference between Pandera and Great Expectations?
Pandera is lightweight and code-first — you define schemas in Python. Great Expectations is heavier and config-first — you define expectations in JSON/YAML. Use Pandera for pipeline validation, Great Expectations for data profiling and reporting.
Can I use Pandera with Polars?
Yes. Use pandera.polars module and pandera.typing.polars.Series. The API is the same as the pandas version.
Does Pandera support nullable columns?
Yes. Set nullable=True in Column() or Field(). By default, columns are non-nullable.
How do I validate a subset of columns?
Use strict=False (default) and only specify the columns you want to validate. Extra columns are ignored.
Can I generate test data from a schema?
Yes. Use schema.example(size=10) to generate a sample DataFrame that passes validation:
sample = OrderSchema.example(size=5)
print(sample) Related Resources
Build an ETL Pipeline with pandas and Parquet
How to build an extract-transform-load pipeline using pandas for data processing and Parquet for columnar storage with type coercion and validation.
RecipeHigh-Performance DataFrame Operations with Polars
How to use Polars for fast DataFrame operations with lazy evaluation, expression API, streaming, and interop with pandas for large datasets.
RecipeTransform Data in the Warehouse with dbt
How to use dbt for SQL-based data transformations with models, tests, materializations, macros, and incremental loading in a data warehouse.