Pydantic
Data validation using Python type hints as the schema — the standard validation layer under FastAPI. Pydantic v2 (current) is Rust-backed internally — roughly 5-50x faster than v1's pure-Python validator, which is why it's viable even in high-throughput API paths rather than being a "nice but slow" convenience layer.
BaseModel — Core
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
class User(BaseModel):
id: int
name: str
email: EmailStr # validates actual email format, not just "is a string"
age: int = Field(gt=0, lt=150)
bio: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
tags: list[str] = []
user = User(id=1, name="Alice", email="alice@example.com", age=30) # validates immediately, raises on failure
user.model_dump() # -> dict
user.model_dump_json() # -> JSON string
user.model_dump(exclude={"bio"}) # exclude specific fields from output
data = {"id": 1, "name": "Alice", "email": "alice@example.com", "age": 30}
user = User.model_validate(data) # parse + validate from a dict
Why this matters more than "it's convenient": validation happens at object construction, not scattered through business logic as manual if checks — an invalid User simply cannot exist as a Python object in memory; ValidationError is raised before any downstream code ever sees bad data.
Field — Constraints & Metadata
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="Price in USD")
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$") # regex constraint
tags: list[str] = Field(default_factory=list, max_length=10)
default_factory=list here is the same mutable-default-argument fix as [[Python OOP & Data Model]]'s @dataclass discussion — a bare tags: list = [] would share one list across every model instance.
Validators
from pydantic import field_validator, model_validator
class Order(BaseModel):
items: list[str]
total: float
discount: float = 0.0
@field_validator("discount")
@classmethod
def discount_valid(cls, v):
if v < 0 or v > 1:
raise ValueError("discount must be between 0 and 1")
return v
@model_validator(mode="after") # runs after ALL fields are set — has access to self
def total_positive_after_discount(self):
if self.total * (1 - self.discount) < 0:
raise ValueError("discounted total cannot be negative")
return self
@field_validator validates one field in isolation; @model_validator(mode="after") is for cross-field invariants that need the whole object already assembled — pick based on whether the rule concerns one field or a relationship between fields.
Nested Models
class Address(BaseModel):
street: str
city: str
country: str = "India"
class Company(BaseModel):
name: str
address: Address
company = Company(name="Acme", address={"street": "123 Main St", "city": "Pune"})
company.address.city # "Pune" — the dict was auto-coerced into an Address instance
Aliases — Decoupling API Field Names From Python Names
class APIResponse(BaseModel):
user_id: int = Field(alias="userId") # incoming JSON uses camelCase
model_config = {"populate_by_name": True} # allow BOTH "user_id" and "userId" on input
data = {"userId": 1}
r = APIResponse.model_validate(data)
r.user_id # 1 — accessed with Pythonic snake_case internally
r.model_dump(by_alias=True) # {"userId": 1} — serialized back to the API's camelCase convention
This is what lets internal code stay idiomatic Python (snake_case) while the external API contract stays whatever convention it needs (camelCase) — one model, two naming conventions, no manual translation layer.
Discriminated Unions
from typing import Literal, Union, Annotated
from pydantic import Discriminator
class Cat(BaseModel):
type: Literal["cat"]
meows: bool
class Dog(BaseModel):
type: Literal["dog"]
barks: bool
class Pet(BaseModel):
animal: Annotated[Union[Cat, Dog], Discriminator("type")]
pet = Pet.model_validate({"animal": {"type": "cat", "meows": True}})
isinstance(pet.animal, Cat) # True
Without the discriminator, Pydantic would have to try each union member in order until one validates — slower and can silently match the wrong type if two shapes overlap; the discriminator field makes the match deterministic and O(1) instead of trial-and-error.
ValidationError Handling
from pydantic import ValidationError
try:
user = User(id="not-an-int", name="", email="invalid")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["msg"], err["type"])
# ('id',) 'Input should be a valid integer' 'int_parsing'
# ('name',) 'String should have at least 1 character' 'string_too_short'
Structured, per-field error detail (loc, msg, type) is what lets an API return precise 422 responses (see [[API Design Principles]]'s error-format section) rather than one opaque "validation failed" message.
model_config — Model Behavior
class Settings(BaseModel):
model_config = {
"extra": "forbid", # reject unexpected fields — catches API misuse early
"frozen": True, # immutable + hashable model
"from_attributes": True, # allow constructing from an ORM object, not just a dict
}
extra: "forbid" on request models is a genuinely valuable production habit — an unexpected field in a request body (typo, stale client, wrong API version) surfaces as a loud validation error immediately rather than being silently ignored.
Interview Drills
- Why is
field_validatorthe wrong tool for a rule like "total after discount can't be negative," andmodel_validator(mode="after")the right one? — That rule depends on two fields (totalanddiscount) together, not one field in isolation —field_validatoronly sees the single field it's attached to, whilemodel_validator(mode="after")runs once every field is assigned and has full access toselffor cross-field checks. - Why does Pydantic v2 matter for a high-throughput API's latency budget, when v1's validation "worked fine"? — v2's validation core is Rust-backed, not pure Python — for an API validating every request body, this is a real, measurable latency difference (5-50x), not a micro-optimization; at genuine production QPS this can be the difference between validation being negligible overhead versus a meaningful tax on every request.
- How would you accept a request body with
userId(camelCase) but keep your Python model's field nameduser_id? —Field(alias="userId")on the model field, withmodel_config = {"populate_by_name": True}if you also want to accept the snake_case name directly — this decouples the external API contract's naming convention from the internal Python code's idiomatic naming.
Cross-links
[[FastAPI]] · [[Asyncio]] · [[Python OOP & Data Model]] · [[API Design Principles]]