Runtime Type Checking — Pydantic v2
Type hints are validated only by static analyzers (mypy, pyright) by default. Pydantic v2 is the most widely used library for validating and coercing types at runtime. It powers FastAPI, Django Ninja, and many other frameworks.
Pydantic v2 Basics
from pydantic import BaseModel, Field
from datetime import datetime
class User(BaseModel):
id: int
name: str
email: str
age: int
is_active: bool = True
created_at: datetime = Field(default_factory=datetime.now)
# Valid data
user = User(id=1, name="Alice", email="alice@example.com", age=30)
print(user)
print(user.model_dump()) # Convert to dict
print(user.model_dump_json()) # Convert to JSON string
# Automatic coercion: "30" → 30
user2 = User(id="2", name="Bob", email="bob@example.com", age="25")
print(type(user2.id)) # <class 'int'>
print(type(user2.age)) # <class 'int'>
# Validation error
from pydantic import ValidationError
try:
User(id="not_an_int", name="Charlie", email="test", age=-5)
except ValidationError as e:
print(e)
Detailed Validation Rules with Field
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, le=1_000_000) # > 0, <= 1,000,000
stock: int = Field(ge=0, default=0) # >= 0
description: str | None = Field(default=None, max_length=500)
tags: list[str] = Field(default_factory=list, max_length=10)
p = Product(name="Python Book", price=35.0, stock=50)
print(p)
# Detailed error messages
from pydantic import ValidationError
try:
Product(name="", price=-100, stock=-5)
except ValidationError as e:
for error in e.errors():
print(f"Field: {error['loc']}, Error: {error['msg']}")
# Reusable types with Annotated
from typing import Annotated
from pydantic import StringConstraints
NonEmptyStr = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]
PositiveFloat = Annotated[float, Field(gt=0)]
class Order(BaseModel):
product_name: NonEmptyStr
quantity: int = Field(ge=1)
unit_price: PositiveFloat
total: float = 0.0
def model_post_init(self, __context) -> None:
self.total = self.quantity * self.unit_price
order = Order(product_name=" Python Book ", quantity=2, unit_price=35.0)
print(f"Product: {order.product_name!r}, Total: ${order.total}") # Whitespace auto-stripped
Validators and model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Any
class PasswordForm(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8)
password_confirm: str
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must contain only letters and numbers.")
return v.lower()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter.")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit.")
return v
@model_validator(mode="after")
def passwords_match(self) -> "PasswordForm":
if self.password != self.password_confirm:
raise ValueError("Password and confirmation do not match.")
return self
# Valid input
form = PasswordForm(
username="Alice123",
password="SecurePass1",
password_confirm="SecurePass1"
)
print(f"Username: {form.username}")
# Error: password mismatch
from pydantic import ValidationError
try:
PasswordForm(
username="Bob",
password="SecurePass1",
password_confirm="DifferentPass1"
)
except ValidationError as e:
print(e)
Nested Models and JSON Serialization
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class Status(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class Address(BaseModel):
street: str
city: str
country: str = "USA"
postal_code: str
class OrderItem(BaseModel):
product_id: int
product_name: str
quantity: int = Field(ge=1)
unit_price: float = Field(gt=0)
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
class Order(BaseModel):
order_id: str
customer_name: str
shipping_address: Address
items: list[OrderItem] = Field(min_length=1)
status: Status = Status.PENDING
created_at: datetime = Field(default_factory=datetime.now)
@property
def total(self) -> float:
return sum(item.subtotal for item in self.items)
# Create nested model
order = Order(
order_id="ORD-001",
customer_name="Alice",
shipping_address={"street": "123 Main St", "city": "New York", "postal_code": "10001"},
items=[
{"product_id": 1, "product_name": "Python Book", "quantity": 2, "unit_price": 35.0},
{"product_id": 2, "product_name": "Laptop Sleeve", "quantity": 1, "unit_price": 25.0},
]
)
print(f"Order ID: {order.order_id}")
print(f"Total: ${order.total:.2f}")
print(f"Shipping to: {order.shipping_address.city}")
# JSON conversion
import json
json_str = order.model_dump_json(indent=2)
print(json_str[:200])
# Restore from JSON
order2 = Order.model_validate_json(json_str)
print(f"Restored order: {order2.order_id}, status: {order2.status}")
Settings Class (BaseSettings)
# pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class AppSettings(BaseSettings):
"""Settings class that automatically reads from environment variables"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Environment variable names: APP_NAME, DATABASE_URL, etc.
app_name: str = "My Application"
debug: bool = False
database_url: str = "sqlite:///./app.db"
secret_key: str = Field(min_length=32, default="dev-secret-key-change-in-production")
allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
max_connections: int = Field(ge=1, le=100, default=10)
# Usage
settings = AppSettings()
print(f"App name: {settings.app_name}")
print(f"Debug mode: {settings.debug}")
print(f"DB URL: {settings.database_url}")
Pro Tips
1. Controlling Behavior with model_config
from pydantic import BaseModel, ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(
strict=True, # Strict mode: no automatic coercion
frozen=True, # Immutable object (hashable)
extra="forbid", # Reject undefined fields
validate_assignment=True, # Validate on attribute assignment
)
id: int
name: str
# strict=True: "1" is not converted to int
from pydantic import ValidationError
try:
StrictUser(id="1", name="Alice")
except ValidationError as e:
print("Strict mode errors:", e.error_count())
# frozen=True: immutable
user = StrictUser(id=1, name="Alice")
try:
user.name = "Bob"
except Exception as e:
print(f"Immutability error: {e}")
### 2. Custom Types with Annotated
from typing import Annotated
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
class PhoneNumber(str):
"""Custom type for US phone number validation"""
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: type, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.no_info_plain_validator_function(
cls._validate,
serialization=core_schema.plain_serializer_function_ser_schema(str),
)
@classmethod
def _validate(cls, value: str) -> "PhoneNumber":
import re
cleaned = re.sub(r"[^0-9]", "", value)
if not re.match(r"^1?\d{10}$", cleaned):
raise ValueError(f"Invalid phone number: {value}")
return cls(cleaned)
class Contact(BaseModel):
name: str
phone: PhoneNumber
c = Contact(name="Alice", phone="1-800-555-1234")
print(c.phone) # 18005551234 (normalized)
Summary
| Feature | How | Description |
|---|---|---|
| Basic validation | Inherit BaseModel | Automatic validation from type annotations |
| Detailed constraints | Field(gt=, min_length=) | Value range/length limits |
| Field validation | @field_validator | Custom logic for a single field |
| Model validation | @model_validator | Cross-field validation |
| Settings management | BaseSettings | Automatic env variable loading |
| Serialization | model_dump(), model_dump_json() | Convert to dict/JSON |
Pydantic v2 uses a Rust-based core (pydantic-core), making it up to 50× faster than v1.