Back to Notes

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

  1. Why is field_validator the wrong tool for a rule like "total after discount can't be negative," and model_validator(mode="after") the right one? — That rule depends on two fields (total and discount) together, not one field in isolation — field_validator only sees the single field it's attached to, while model_validator(mode="after") runs once every field is assigned and has full access to self for cross-field checks.
  2. 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.
  3. How would you accept a request body with userId (camelCase) but keep your Python model's field named user_id?Field(alias="userId") on the model field, with model_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]]