Skip to main content
Advertisement

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

FeatureHowDescription
Basic validationInherit BaseModelAutomatic validation from type annotations
Detailed constraintsField(gt=, min_length=)Value range/length limits
Field validation@field_validatorCustom logic for a single field
Model validation@model_validatorCross-field validation
Settings managementBaseSettingsAutomatic env variable loading
Serializationmodel_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.

Advertisement