Skip to content

Pydantic Deep Dive

Pydantic is the validation engine inside FastAPI. The better you know it, the cleaner your API code.

This chapter dives into the features you'll actually use in production: field constraints, validators, computed fields, nested models, and the new Pydantic v2 patterns.

Quick refresher

from pydantic import BaseModel

class Patient(BaseModel):
    name: str
    age: int

p = Patient(name="Alice", age=25)
print(p.name)               # 'Alice'
print(p.model_dump())       # {'name': 'Alice', 'age': 25}
print(p.model_dump_json())  # '{"name":"Alice","age":25}'

Field() — adding rules

Field(default, **rules) lets you set constraints, descriptions, examples:

from pydantic import BaseModel, Field
from typing import Annotated

class Product(BaseModel):
    name: Annotated[str, Field(..., min_length=2, max_length=100,
                                description="Product name", examples=["Pen"])]
    price: Annotated[float, Field(..., gt=0, le=1_000_000)]
    stock: Annotated[int, Field(0, ge=0)]                    # default 0, >=0
    sku: Annotated[str, Field(..., pattern=r"^[A-Z]{3}-\d{4}$")]

# Valid
Product(name="Pen", price=10.5, sku="ABC-1234")
# Invalid: ValueError — gt=0 violated
# Product(name="Pen", price=-5, sku="ABC-1234")

Annotated[T, Field(...)] is the modern syntax. Older name: str = Field(...) also works.

Common Field constraints

Constraint Applies to Meaning
gt / ge / lt / le numbers greater-than / >= / less-than / <=
min_length / max_length str, list length
pattern str regex match
multiple_of numbers must be a multiple
examples any show in Swagger UI
description any docs

Validators — custom checks

When Field rules aren't enough, write a @field_validator:

from pydantic import BaseModel, field_validator

class User(BaseModel):
    email: str
    age: int

    @field_validator("email")
    @classmethod
    def email_must_be_corporate(cls, v: str) -> str:
        if not v.endswith("@example.com"):
            raise ValueError("email must end with @example.com")
        return v.lower()      # normalize while we're here

    @field_validator("age")
    @classmethod
    def reasonable_age(cls, v: int) -> int:
        if v > 150:
            raise ValueError("age too large")
        return v

# Valid + lowercased
User(email="Alice@Example.com", age=25)

# Raises ValidationError
# User(email="alice@gmail.com", age=25)

Validators run after the type conversion. They can: - Reject the value (raise). - Transform the value (return a different value).

Cross-field validation — @model_validator

Some rules involve two or more fields:

from pydantic import BaseModel, model_validator

class DateRange(BaseModel):
    start: int
    end: int

    @model_validator(mode="after")
    def end_after_start(self):
        if self.end < self.start:
            raise ValueError("end must be >= start")
        return self

DateRange(start=1, end=5)        # ok
# DateRange(start=10, end=5)     # ValidationError

Computed fields — derived data

Compute a value from other fields automatically:

from pydantic import BaseModel, computed_field

class Person(BaseModel):
    height: float        # meters
    weight: float        # kg

    @computed_field
    @property
    def bmi(self) -> float:
        return round(self.weight / (self.height ** 2), 2)

    @computed_field
    @property
    def bmi_category(self) -> str:
        if self.bmi < 18.5:  return "Underweight"
        if self.bmi < 25:    return "Normal"
        if self.bmi < 30:    return "Overweight"
        return "Obese"

p = Person(height=1.7, weight=70)
print(p.model_dump())
# {'height': 1.7, 'weight': 70, 'bmi': 24.22, 'bmi_category': 'Normal'}

bmi and bmi_category appear in model_dump() and in the API response — but they're not required in the input.

Useful built-in types

from pydantic import BaseModel, EmailStr, HttpUrl, SecretStr
from datetime import date, datetime
from uuid import UUID

class User(BaseModel):
    id: UUID
    email: EmailStr          # validates email format
    website: HttpUrl         # validates URL
    password: SecretStr      # masked in str() / repr()
    birthdate: date
    created_at: datetime

EmailStr and HttpUrl need extras:

pip install "pydantic[email]"

Nested models

class Address(BaseModel):
    street: str
    city: str
    pin: str = Field(..., pattern=r"^\d{6}$")

class Company(BaseModel):
    name: str
    address: Address
    employees: list[str] = []

c = Company(
    name="Acme",
    address={"street": "MG Rd", "city": "Bangalore", "pin": "560001"},
    employees=["Alice", "Bob"],
)

Optional and nullable fields

class Profile(BaseModel):
    name: str
    bio: str | None = None             # optional, may be None
    twitter: str = ""                  # optional, defaults to empty string
    skills: list[str] = []             # optional, defaults to empty list

Rule of thumb: default to None for "missing / unknown", [] or "" for "explicitly empty".

Model configuration

Control behavior with model_config:

from pydantic import BaseModel, ConfigDict

class User(BaseModel):
    model_config = ConfigDict(
        str_strip_whitespace=True,        # auto-strip spaces from strings
        str_to_lower=False,
        frozen=False,                      # immutability
        extra="forbid",                    # reject unknown fields
        # extra="allow"   to keep extras
        # extra="ignore"  (default) to drop silently
    )
    name: str
    age: int

extra="forbid" is great for catching typos in keys.

Strict mode — no coercion

Pydantic v2 doesn't auto-convert "25" to 25 for int fields by default (good!). Even stricter — turn off ANY coercion:

from pydantic import BaseModel, Field

class Item(BaseModel):
    quantity: int = Field(strict=True)

Item(quantity=5)         # ok
# Item(quantity=5.0)      # rejected
# Item(quantity="5")      # rejected

Aliases — different field names in / out

from pydantic import BaseModel, Field

class User(BaseModel):
    user_id: int = Field(alias="userID")        # incoming JSON has "userID"
    full_name: str = Field(alias="fullName")

# Accept either alias or actual name
u = User.model_validate({"userID": 1, "fullName": "Alice"})
print(u.user_id, u.full_name)

# Dump using aliases
print(u.model_dump(by_alias=True))
# {'userID': 1, 'fullName': 'Alice'}

Great for matching external API conventions while keeping snake_case in Python.

Converting to/from JSON & dicts

class Movie(BaseModel):
    title: str
    year: int

m = Movie(title="Inception", year=2010)

m.model_dump()              # dict
m.model_dump_json()         # JSON string
m.model_dump_json(indent=2) # pretty JSON

Movie.model_validate({"title": "Tenet", "year": 2020})         # from dict
Movie.model_validate_json('{"title":"Tenet","year":2020}')      # from JSON

Serializers — control how a field is dumped

from pydantic import BaseModel, field_serializer
from datetime import datetime

class Event(BaseModel):
    name: str
    when: datetime

    @field_serializer("when")
    def fmt_when(self, dt: datetime) -> str:
        return dt.strftime("%Y-%m-%d %H:%M")

e = Event(name="Conf", when=datetime(2025, 5, 1, 14, 30))
print(e.model_dump_json())
# {"name":"Conf","when":"2025-05-01 14:30"}

Pydantic v2 model methods cheatsheet

Method Use
model.model_dump() Convert to dict
model.model_dump(exclude={"x"}) Skip fields
model.model_dump(exclude_unset=True) Only fields that were explicitly set
model.model_dump(by_alias=True) Use aliases
model.model_dump_json() Convert to JSON string
Model.model_validate(d) Build from dict
Model.model_validate_json(s) Build from JSON
Model.model_json_schema() Get the JSON schema
Model.model_fields Inspect declared fields

How Pydantic powers FastAPI

When you write:

@app.post("/users")
def create_user(user: User):
    ...

FastAPI:

  1. Reads the type hint User.
  2. Auto-generates the request schema for Swagger docs.
  3. Validates incoming JSON against the model — before your function runs.
  4. Builds a User instance and passes it in.
  5. Uses the model again for the response schema if you set response_model=.

You write Pydantic models. FastAPI does the rest.

Common pitfalls

  • Mixing v1 and v2 syntax — v1: @validator, Config class, .dict(). v2: @field_validator, model_config, .model_dump(). Use v2 for new code.
  • Forgetting @classmethod on validators — required in v2.
  • Using extra="allow" as the default — silently lets clients send junk fields. Prefer "forbid" or default "ignore".
  • Field(..., default=0)... means required. Don't pass default= with it.
  • Validators that mutate the wrong thing — return the validated value, don't modify self inside a field_validator.

What's next

Practice

What does this print?

Expected: Alice

from pydantic import BaseModel
class User(BaseModel):
    name: str
u = User(name="Alice")
print(u.name)

Use a Pydantic validator to enforce age > 0 (no negative ages)

Expected: True

from pydantic import BaseModel, Field
class User(BaseModel):
    age: int = Field(ge=-100)         # bug: allows negatives; should be ge=0
try:
    User(age=-5)
    accepted = True
except Exception:
    accepted = False
print(accepted)

Quiz — Quick check

What you remember

Q1. What does Field(ge=0) do?

  • Constraint: value must be >= 0; otherwise raises ValidationError
  • Makes the field required
  • Sets a default
  • Marks it as a foreign key

Why: Field(ge=N) means "greater than or equal to N". Other constraints: gt, le, lt, min_length, max_length, pattern (regex), etc. All show up in OpenAPI docs.

Q2. How do you make a field optional?

  • Give it a default: name: str | None = None or name: str = "default"
  • optional: True
  • nullable: True
  • Wrap in a function

Why: A default value (including None) makes the field optional. Without a default, Pydantic requires it.

Q3. What's the difference between @field_validator and @model_validator?

  • field_validator validates one field; model_validator(mode="after") validates relationships between fields
  • No difference
  • model_validator is deprecated
  • field_validator only works on strings

Why: Use field_validator for "is this email valid?". Use model_validator(mode="after") for "do start_date and end_date make sense together?"—needs access to multiple fields.

Common doubts

Should I use Pydantic v1 or v2?

v2 for new code. It's faster, stricter, better designed. Migration from v1 is mostly mechanical (renames: ConfigConfigDict, @validator@field_validator). The Pydantic v2 migration guide covers everything.

When should I use Pydantic outside FastAPI?

Anywhere you need data validation: config files (replace argparse + manual validation), API responses (validate untrusted external data), CLI tools (validate args). Pydantic is great wherever you have data that needs schema.

How do I serialize a Pydantic model to JSON?

model.model_dump_json() returns a JSON string. model.model_dump() returns a dict. To customize: mode="json" for JSON-friendly types, exclude={"password"} to drop fields, by_alias=True for aliased field names.

Async / Await — When & How