🎯 Learning Objectives
- Define Pydantic v2 models with typed fields, defaults, and
Field()constraints - Use built-in validators:
EmailStr,AnyUrl,constr,conint,confloat - Write
@field_validatorand@model_validatorfor custom validation logic - Understand Pydantic's coercion rules and strict mode
- Use
model_dump(),model_dump_json(),model_validate(), andmodel_validate_json() - Compose models with nested objects and discriminated unions
- Configure models with
model_config(aliases, extra fields, frozen, populate by name)
1 · Why Pydantic?
Pydantic is a data validation and settings management library that leverages Python type annotations. Rather than writing manual checks scattered through your code, you declare a model class, and Pydantic guarantees that any instance satisfies its constraints.
Pydantic v2 (released 2023) rewrote the validation core in Rust, delivering 5–50× faster validation than v1. While it powers FastAPI, Pydantic is equally useful standalone — for CLI tools, config loading, ETL pipelines, and anywhere you receive untrusted data.
The core philosophy is parse, don't validate: transform raw, untyped data into typed, trusted Python objects in a single step. If something is wrong, you get a rich, structured error — not a vague crash two functions later.
Compare the traditional approach with Pydantic:
# Without Pydantic — manual, error-prone
def create_user(data: dict):
if "name" not in data or not isinstance(data["name"], str):
raise ValueError("name must be a string")
if "age" not in data or not isinstance(data["age"], int) or data["age"] < 0:
raise ValueError("age must be a non-negative integer")
if "email" not in data or "@" not in data["email"]:
raise ValueError("invalid email")
return {"name": data["name"], "age": data["age"], "email": data["email"]}
# With Pydantic — declarative, automatic
from pydantic import BaseModel, EmailStr
class User(BaseModel):
name: str
age: int
email: EmailStr
user = User(name="Alice", age=30, email="alice@example.com") # validated + typedcomparison.pyValidationError with structured, human-readable error details — not bare ValueError strings. Every error includes the field path, the offending value, and the reason.2 · BaseModel Basics
A Pydantic model is a class that inherits from BaseModel. Fields are declared with type annotations. You control defaults, constraints, and metadata through Field().
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class Product(BaseModel):
id: int # required
name: str = Field(min_length=1, max_length=100)
description: Optional[str] = None # optional, defaults to None
price: float = Field(gt=0, description="Price in USD, must be positive")
stock: int = Field(default=0, ge=0) # ge = greater-than-or-equal
created_at: datetime = Field(default_factory=datetime.utcnow)
tags: list[str] = Field(default_factory=list)
# Instantiation
p = Product(id=1, name="Widget", price=9.99)
print(p.name) # Widget
print(p.stock) # 0 (default)
print(p.created_at) # datetime object
# Serialisation
print(p.model_dump()) # dict with all fields
print(p.model_dump_json()) # JSON string
print(p.model_dump(exclude={"created_at"})) # exclude fields
print(p.model_dump(include={"id", "name", "price"})) # include onlybasemodel_basics.pyThe Field() function accepts many constraint parameters:
| Parameter | Type | Purpose |
|---|---|---|
gt | numeric | Greater than |
ge | numeric | Greater than or equal |
lt | numeric | Less than |
le | numeric | Less than or equal |
min_length | int | Minimum string/list length |
max_length | int | Maximum string/list length |
pattern | str (regex) | String must match regex |
description | str | Human-readable description (shows in JSON Schema) |
default | any | Default value |
default_factory | callable | Factory for mutable defaults |
alias | str | Alternate name for parsing input |
repr | bool | Include in __repr__ output |
exclude | bool | Exclude from serialisation by default |
3 · Built-in Types & Constrained Types
Pydantic ships with many specialised types that validate common formats out of the box:
from pydantic import (
BaseModel, EmailStr, AnyUrl, AnyHttpUrl,
constr, conint, confloat, PositiveInt, PositiveFloat,
NonNegativeInt,
)
from pydantic.networks import IPvAnyAddress
from typing import Optional
from uuid import UUID
from decimal import Decimal
class SignupForm(BaseModel):
username: constr(min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$')
email: EmailStr
age: conint(ge=13, le=120)
score: confloat(ge=0.0, le=100.0)
website: Optional[AnyHttpUrl] = None
user_id: UUID
balance: Decimal = Decimal("0.00")
ip_addr: Optional[IPvAnyAddress] = Nonebuiltin_types.pyEmailStr requires the optional dependency: pip install pydantic[email]. AnyUrl validates URL structure (scheme, host, etc.). UUID automatically coerces strings like "550e8400-e29b-..." into uuid.UUID objects. Use Decimal for money — it preserves precision unlike float.
In Pydantic v2, the preferred way to define constrained types is with Annotated:
from typing import Annotated
from pydantic import BaseModel, Field
Username = Annotated[str, Field(min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$')]
PositivePrice = Annotated[float, Field(gt=0)]
class Item(BaseModel):
name: Username
price: PositivePriceannotated_types.pyAnnotated type aliases at module level — they self-document and can be shared across models without repeating constraints.4 · Field Validators (@field_validator)
When built-in constraints aren't enough, use @field_validator to run custom logic on individual fields. Validators can reject values (raise ValueError) or transform them (return a modified value).
from pydantic import BaseModel, field_validator, ValidationInfo
from typing import Optional
class UserProfile(BaseModel):
username: str
password: str
confirm_password: str
bio: Optional[str] = None
@field_validator("username")
@classmethod
def username_no_spaces(cls, v: str) -> str:
if " " in v:
raise ValueError("username cannot contain spaces")
return v.lower() # validators can transform values
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("password must contain at least one uppercase letter")
return v
@field_validator("bio", mode="before") # mode="before" runs before type coercion
@classmethod
def strip_bio(cls, v):
if isinstance(v, str):
return v.strip() or None
return vfield_validators.pymode="before" — the validator runs on the raw input, before Pydantic attempts type coercion. This is useful when you need to pre-process or normalise messy input.
mode="after" (the default) — the validator runs after coercion, so v is already the correct Python type. Use this for business-logic checks.
Returning a value from a validator replaces the field value. This means validators double as transformers — you can lowercase usernames, strip whitespace, or compute derived values.
5 · Model Validators (@model_validator)
Model validators have access to all fields at once, making them ideal for cross-field checks (e.g., "start must be before end") or computed fields that depend on multiple inputs.
from pydantic import BaseModel, model_validator
from typing import Self
class DateRange(BaseModel):
start_date: str
end_date: str
max_days: int = 365
@model_validator(mode="after")
def check_date_order(self) -> Self:
if self.start_date >= self.end_date:
raise ValueError("start_date must be before end_date")
return self
class DiscountedProduct(BaseModel):
price: float
discount_pct: float = 0.0
final_price: float = 0.0
@model_validator(mode="after")
def compute_final_price(self) -> Self:
# model validators can set computed fields
self.final_price = round(self.price * (1 - self.discount_pct / 100), 2)
return selfmodel_validators_after.pymode="after" — receives the fully validated model instance. You access fields via self and return Self.
mode="before" — receives the raw input (typically a dict) before any field validation. You can reshape the data entirely:
from pydantic import BaseModel, model_validator
class Config(BaseModel):
host: str
port: int
@model_validator(mode="before")
@classmethod
def parse_url(cls, data):
# Accept either a dict or a "host:port" string
if isinstance(data, str):
host, port = data.split(":")
return {"host": host, "port": int(port)}
return data
cfg = Config.model_validate("localhost:5432")
print(cfg.host, cfg.port) # localhost 5432model_validators_before.pymode="before" model validators, the data is unvalidated — always check types defensively and handle unexpected formats gracefully.6 · Parsing & Serialisation
Pydantic models are both parsers (raw data → typed object) and serialisers (typed object → dict/JSON). The v2 API uses consistent model_* method names.
from pydantic import BaseModel
from datetime import datetime
class Event(BaseModel):
title: str
timestamp: datetime
attendees: list[str]
# ── Parsing ──
# From dict
e1 = Event.model_validate({
"title": "PyCon",
"timestamp": "2024-06-01T09:00:00", # string → datetime coercion
"attendees": ["Alice", "Bob"],
})
# From JSON string
e2 = Event.model_validate_json(
'{"title":"DjangoCon","timestamp":"2024-09-10T10:00:00","attendees":["Carol"]}'
)
# ── Serialisation ──
print(e1.model_dump())
# {"title": "PyCon", "timestamp": datetime(2024, 6, 1, 9, 0), "attendees": [...]}
print(e1.model_dump(mode="json")) # datetime → ISO string, ready for json.dumps
print(e1.model_dump_json()) # full JSON string directly
# Partial serialisation
print(e1.model_dump(include={"title", "timestamp"}))
print(e1.model_dump(exclude={"attendees"}))
print(e1.model_dump(exclude_none=True)) # skip None fields
print(e1.model_dump(exclude_unset=True)) # skip fields not explicitly set by callerparsing_serialisation.pyexclude_unset=True is critical for PATCH endpoints — only fields the client explicitly sent are included in the output, so you don't accidentally overwrite database values with defaults for fields the user never intended to change.7 · Nested Models & Discriminated Unions
Real-world data is rarely flat. Pydantic validates nested models recursively — just use one model as a field type inside another. Raw dicts are automatically coerced into the nested model.
from pydantic import BaseModel, Field
from typing import Literal, Union, Annotated
# ── Nested models ──
class Address(BaseModel):
street: str
city: str
country: str = "US"
class Company(BaseModel):
name: str
address: Address # nested model — validated recursively
employees: list[str] = []
c = Company(
name="Acme",
address={"street": "123 Main St", "city": "Springfield"}, # dict auto-coerced
)
print(c.address.city) # Springfield
# ── Discriminated unions ──
class Cat(BaseModel):
type: Literal["cat"]
meows_per_day: int
class Dog(BaseModel):
type: Literal["dog"]
barks_per_day: int
Pet = Annotated[Union[Cat, Dog], Field(discriminator="type")]
class Owner(BaseModel):
name: str
pet: Pet
o = Owner(name="Alice", pet={"type": "cat", "meows_per_day": 42})
print(type(o.pet)) # <class 'Cat'>
print(o.pet.meows_per_day) # 42nested_unions.pyDiscriminated unions use a literal field (the "discriminator") to decide which model to validate against. Benefits over plain Union:
- Performance: O(1) lookup by discriminator value instead of trying each model sequentially.
- Error messages: Pydantic reports exactly which variant failed and why, instead of confusing multi-model errors.
- OpenAPI generation: FastAPI produces clean, unambiguous schema docs with a
discriminatormapping.
Model Configuration with model_config
Pydantic v2 uses a model_config class variable (a ConfigDict)
to control model behaviour — replacing v1's inner class Config.
from pydantic import BaseModel, ConfigDict, Field
class StrictUser(BaseModel):
model_config = ConfigDict(
strict=True, # no coercion — int("3") raises, must pass int
frozen=True, # instances are immutable (like frozen dataclass)
populate_by_name=True, # allow both alias and field name when parsing
str_strip_whitespace=True, # auto-strip leading/trailing whitespace from str fields
extra="forbid", # raise if extra fields are passed
)
username: str = Field(alias="user_name")
age: int
u = StrictUser(user_name="alice", age=30) # alias works
u = StrictUser(username="alice", age=30) # field name also works (populate_by_name)
# u.username = "bob" → raises — frozen=True
model_config.py
Key ConfigDict Options
| Option | Default | Effect |
|---|---|---|
strict | False | Disable all type coercion — values must already be the correct type |
frozen | False | Make instances immutable; enables hashing |
extra | "ignore" | "allow" stores extras, "forbid" raises, "ignore" silently drops |
populate_by_name | False | Accept both the field name and its alias during parsing |
str_strip_whitespace | False | Strip leading/trailing whitespace from all str fields |
str_to_lower | False | Lowercase all str fields after parsing |
use_enum_values | False | Store the enum's value rather than the enum member |
validate_default | False | Run validators on default values too |
from_attributes | False | Allow building models from ORM objects (model.attr instead of dict) |
from_attributes=True to parse SQLAlchemy
or Django ORM model instances directly:
UserOut.model_validate(db_user) — no manual .dict() needed.
Computed Fields & Custom Serialisers
from pydantic import BaseModel, computed_field, field_serializer
from datetime import date
class Person(BaseModel):
first_name: str
last_name: str
birth_date: date
@computed_field # included in model_dump() automatically
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@computed_field
@property
def age(self) -> int:
today = date.today()
return today.year - self.birth_date.year - (
(today.month, today.day) < (self.birth_date.month, self.birth_date.day)
)
@field_serializer("birth_date") # custom serialiser for one field
def serialize_date(self, v: date) -> str:
return v.strftime("%d %b %Y") # "15 Jun 1990"
p = Person(first_name="Alice", last_name="Smith", birth_date="1990-06-15")
print(p.full_name) # Alice Smith
print(p.model_dump()) # includes full_name, age; birth_date as "15 Jun 1990"
computed_fields.py
Pydantic Settings — Config from Env Vars
pydantic-settings extends Pydantic for application configuration —
loading values from environment variables, .env files, secrets,
and JSON with the same validation pipeline.
pip install pydantic-settings
terminal
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import AnyUrl, Field
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", # load from .env in addition to real env vars
env_file_encoding="utf-8",
case_sensitive=False, # DATABASE_URL == database_url
)
app_name: str = "MyAPI"
debug: bool = False
database_url: AnyUrl
secret_key: str = Field(min_length=32)
allowed_hosts: list[str] = ["localhost"]
max_connections: int = Field(default=10, ge=1, le=100)
# Instantiation reads from env vars (or .env file)
# export DATABASE_URL=postgresql://user:pass@localhost/db
# export SECRET_KEY=supersecretkey12345678901234567890
settings = Settings()
print(settings.database_url)
print(settings.debug) # False unless DEBUG=true in env
config.py
# .env file example
APP_NAME=ProductionAPI
DEBUG=false
DATABASE_URL=postgresql://user:pass@db.host/mydb
SECRET_KEY=a-very-long-random-secret-key-goes-here-1234
ALLOWED_HOSTS=["api.example.com","www.example.com"]
.env
Use a cached singleton in FastAPI with lru_cache:
from functools import lru_cache
from fastapi import Depends
@lru_cache
def get_settings() -> Settings:
return Settings()
# In a route or dependency:
from fastapi import FastAPI
app = FastAPI()
@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
return {"app": settings.app_name, "debug": settings.debug}
main.py
model_config or source files. Always load from
environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault).
The .env file is for local development only — add it to .gitignore.
Best Practices
- Define separate request and response models —
UserCreate(has password),UserOut(no password),UserUpdate(all Optional). Never reuse one model for all three roles. - Use
Annotatedtype aliases for reusable constraints —PositivePrice = Annotated[float, Field(gt=0)]— define once, use everywhere. - Prefer
@model_validator(mode="after")for cross-field validation; prefer@field_validatorfor single-field rules. - Use
exclude_unset=Truein PATCH handlers — only fields the client sent will appear in the dict, preventing accidental overwrites. - Set
extra="forbid"on request models — reject unknown fields to prevent parameter pollution attacks. - Use
from_attributes=Truewhen integrating with ORMs — lets you callUserOut.model_validate(db_row)without a manual conversion layer. - Use
pydantic-settingsfor all configuration — typed, validated, .env-aware config beatsos.getenv()scattered through your codebase. - Validators should raise
ValueError(orAssertionError) — Pydantic wraps them intoValidationErrorautomatically with field context.
Exercises
Exercise 1 — User Registration Models
Build a complete set of Pydantic models for a user registration system:
UserCreate:username(3–20 chars, alphanumeric + underscore),email(EmailStr),password(min 8, must have uppercase + digit),age(13–120).UserOut:id(int),username,email,created_at(datetime). No password field.UserUpdate: all fields Optional except nothing is required.- Add a
@model_validator(mode="after")onUserCreatethat raises ifusernameis a substring ofpassword(weak password). - Demonstrate: valid creation, a ValidationError for weak password, and
model_dump(exclude_unset=True)on a partial update.
💡 Hint
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
from pydantic import ConfigDict
from typing import Optional, Annotated
from datetime import datetime
UsernameStr = Annotated[str, Field(min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$')]
class UserCreate(BaseModel):
username: UsernameStr
email: EmailStr
password: str = Field(min_length=8)
age: int = Field(ge=13, le=120)
@field_validator("password")
@classmethod
def password_complexity(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("must contain an uppercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("must contain a digit")
return v
@model_validator(mode="after")
def password_not_username(self):
if self.username.lower() in self.password.lower():
raise ValueError("password must not contain the username")
return self
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
created_at: datetime
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
username: Optional[UsernameStr] = None
email: Optional[EmailStr] = None
age: Optional[int] = Field(default=None, ge=13, le=120)
Exercise 2 — Discriminated Union for Notifications
Model a notification system that supports multiple delivery channels:
EmailNotification:type: Literal["email"],to_address: EmailStr,subject: str,body: str.SMSNotification:type: Literal["sms"],phone_number: str(E.164 format, e.g.+14155551234),message: str(max 160 chars).WebhookNotification:type: Literal["webhook"],url: AnyHttpUrl,payload: dict.- Create a
NotificationRequestmodel with anotificationfield using a discriminated union ontype. - Parse three JSON strings (one per type) and confirm the correct class is instantiated.
💡 Hint
from pydantic import BaseModel, EmailStr, AnyHttpUrl, Field
from pydantic.networks import AnyHttpUrl
from typing import Literal, Union, Annotated
class EmailNotification(BaseModel):
type: Literal["email"]
to_address: EmailStr
subject: str
body: str
class SMSNotification(BaseModel):
type: Literal["sms"]
phone_number: str = Field(pattern=r'^\+[1-9]\d{7,14}$')
message: str = Field(max_length=160)
class WebhookNotification(BaseModel):
type: Literal["webhook"]
url: AnyHttpUrl
payload: dict
Notification = Annotated[
Union[EmailNotification, SMSNotification, WebhookNotification],
Field(discriminator="type")
]
class NotificationRequest(BaseModel):
notification: Notification
# Test
r = NotificationRequest.model_validate_json(
'{"notification": {"type": "sms", "phone_number": "+14155551234", "message": "Hi!"}}'
)
print(type(r.notification)) # SMSNotification
Exercise 3 — Settings & FastAPI Integration
Wire pydantic-settings into a small FastAPI app:
- Create a
Settingsmodel with:app_name: str,debug: bool = False,database_url: AnyUrl,api_key: str = Field(min_length=20). - Load from a
.envfile usingSettingsConfigDict(env_file=".env"). - Cache the settings with
@lru_cacheand expose viaDepends(get_settings). - Add a
GET /configroute that returnsapp_nameanddebug(never exposeapi_keyordatabase_url). - Write a pytest test that overrides the dependency with a test settings object using
app.dependency_overrides.
💡 Hint — test with dependency override
from fastapi.testclient import TestClient
from main import app, get_settings
from config import Settings
def get_test_settings():
return Settings(
app_name="TestApp",
debug=True,
database_url="postgresql://test:test@localhost/test",
api_key="test-api-key-at-least-20-chars",
)
app.dependency_overrides[get_settings] = get_test_settings
client = TestClient(app)
def test_config_endpoint():
resp = client.get("/config")
assert resp.status_code == 200
assert resp.json()["app_name"] == "TestApp"
assert "api_key" not in resp.json()