FastAPI Mastery
22 topics · Comprehensive course
Topic 5 / 22 — Pydantic
Topic 5 · Pydantic
5.1 — Models
Pydantic is FastAPI's superpower — it lets you define the shape of data using Python type hints, then automatically validates, parses, and documents it. Think of a Pydantic model as a smart Python class that enforces a contract on every piece of data that flows through it.
💡 Real-world analogy
A Pydantic model is like a customs form at an airport. Every traveller must fill in their name, passport number, and destination. If a field is missing or wrong, they're flagged. If everything checks out, they get stamped and proceed — just like your API request.
5.1.1 BaseModel
📦
What is BaseModel?

BaseModel is the foundation of all Pydantic models. You inherit from it to create a schema. Pydantic reads your Python type annotations and uses them to validate and coerce incoming data automatically.

python
from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True  # default value

# Valid usage
user = User(id=1, name="Alice", email="alice@example.com")
print(user.name)    # Alice
print(user.is_active) # True (default)

# Pydantic auto-coerces: "1" → 1
user2 = User(id="1", name="Bob", email="bob@example.com")
print(user2.id)     # 1 (int, not "1")
Pydantic coerces data — if you pass the string "1" for an int field, it will convert it to 1 silently. But if you pass "hello" for an int, it raises a ValidationError.
🏷️
Field Declaration

Fields are declared as class-level annotations. You can attach metadata, constraints, and descriptions using the Field() function from Pydantic.

python
from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(
        ...,                           # ... means required
        min_length=2,
        max_length=100,
        description="Product name",
        examples=["Laptop", "Phone"]
    )
    price: float = Field(
        ...,
        gt=0,        # greater than 0
        le=99999,   # less than or equal
        description="Price in USD"
    )
    stock: int = Field(default=0, ge=0)  # >= 0
    sku: str = Field(default="", pattern=r"^[A-Z]{3}-\d{4}$")
...
Required field (no default). Same as having no default at all.
gt / ge
Greater than / greater than or equal (for numbers)
lt / le
Less than / less than or equal
min_length
Minimum string length
max_length
Maximum string length
pattern
Regex pattern the string must match
🎯
Defaults & Optional Fields

Fields with a default value are optional at creation time. Use Optional[T] (or T | None in Python 3.10+) when the field can explicitly be None.

python
from typing import Optional
from pydantic import BaseModel

class Article(BaseModel):
    title: str                     # required, cannot be None
    content: str                   # required
    subtitle: Optional[str] = None # optional, can be None
    views: int = 0                  # optional with default
    tags: list[str] = []           # default empty list

# Creating with only required fields
a = Article(title="FastAPI Guide", content="...")
print(a.subtitle)  # None
print(a.views)     # 0
print(a.tags)      # []
⚠️
Mutable defaults: Never use tags: list = [] as a plain Python default — Pydantic handles this correctly by creating a new list per instance, but always prefer Field(default_factory=list) for clarity in complex cases.
🛡️
Type Validation & Error Messages

When validation fails, Pydantic raises a ValidationError with clear, structured error messages. Each error tells you the field, location, and reason.

python
from pydantic import BaseModel, ValidationError

class Order(BaseModel):
    item: str
    quantity: int
    price: float

try:
    o = Order(item="widget", quantity="lots", price="cheap")
except ValidationError as e:
    print(e.error_count())   # 2 errors
    for err in e.errors():
        print(err['loc'], err['msg'])
    # ('quantity',) Input should be a valid integer
    # ('price',)   Input should be a valid number
🧪 Validation Playground
→ Click a button above to see Pydantic validation in action...
🔄
Type Conversion (Coercion)

Pydantic v2 performs lax coercion by default — it tries to convert compatible types before raising an error. This is why JSON strings coming from HTTP work seamlessly.

InputField TypeResultNotes
"42"int42String → int coercion
"3.14"float3.14String → float coercion
1boolTrueint → bool
"true"boolTrueString → bool
"hello"intValidationErrorCannot convert
[1,2,3]tuple(1,2,3)list → tuple
ℹ️
In strict mode (model_config = ConfigDict(strict=True)), no coercion happens — types must match exactly. Useful for internal validation.
🧠
Quick Check — 5.1 Models
You declare age: int in a Pydantic model and pass age="25". What happens?
🔴 A. A ValidationError is raised immediately
🟢 B. Pydantic coerces "25" to 25 (int) — succeeds
🟡 C. The field stores "25" as a string
⚫ D. Python raises a TypeError before Pydantic sees it
Topic 5 · Pydantic
5.2 — Advanced Validation
Beyond type checking, Pydantic lets you write custom validation logic — for individual fields and across the entire model. This is where business rules live.
5.2.1 Field Validators
⬅️
Before Validation (@field_validator mode='before')

A before validator runs before Pydantic converts the value to the declared type. Use it to clean or transform raw input first — like stripping whitespace or uppercasing a code.

python
from pydantic import BaseModel, field_validator

class UserSignup(BaseModel):
    username: str
    email: str
    country_code: str

    @field_validator("username", mode="before")
    @classmethod
    def strip_username(cls, v):
        # Runs BEFORE type coercion
        if isinstance(v, str):
            return v.strip().lower()
        return v

    @field_validator("country_code", mode="before")
    @classmethod
    def normalize_country(cls, v):
        return v.upper() if isinstance(v, str) else v

u = UserSignup(
    username="  Alice  ",
    email="alice@x.com",
    country_code="us"
)
print(u.username)     # "alice" (stripped + lowercased)
print(u.country_code) # "US"
➡️
After Validation (@field_validator mode='after')

An after validator runs after Pydantic has already converted the value to the declared type. The v argument is guaranteed to be the right type. Use it to apply business rules.

python
from pydantic import BaseModel, field_validator, ValidationInfo

class Password(BaseModel):
    value: str

    @field_validator("value", mode="after")
    @classmethod
    def check_strength(cls, v: str) -> str:
        # v is already a str at this point
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        if not any(c.isdigit() for c in v):
            raise ValueError("Password must contain a digit")
        return v

# ✅ Valid
p = Password(value="secure42")

# ❌ Too short
Password(value="abc")  # ValidationError: Password must be at least 8 characters
Inside a @field_validator, raise ValueError (not a custom exception) to produce a Pydantic-friendly error message.
5.2.2 Model Validators — Cross-field Validation
🔗
@model_validator — Validating Multiple Fields Together

Sometimes you need to validate one field relative to another — for example, checking that end_date is after start_date, or that password and confirm_password match. Use @model_validator for this.

Raw Input
Field validators (each field)
model_validator (whole model)
Valid Model Instance
python
from pydantic import BaseModel, model_validator
from datetime import date

class BookingRequest(BaseModel):
    check_in: date
    check_out: date
    guests: int
    room_type: str

    @model_validator(mode="after")
    def validate_dates_and_capacity(self) -> "BookingRequest":
        # self is the model instance — all fields are available
        if self.check_out <= self.check_in:
            raise ValueError("check_out must be after check_in")

        max_guests = 2 if self.room_type == "single" else 4
        if self.guests > max_guests:
            raise ValueError(
                f"Room type '{self.room_type}' allows max {max_guests} guests"
            )
        return self

# ✅ Valid
b = BookingRequest(
    check_in=date(2025,6,1),
    check_out=date(2025,6,5),
    guests=2,
    room_type="single"
)

# ❌ Too many guests for single room
BookingRequest(
    check_in=date(2025,6,1),
    check_out=date(2025,6,5),
    guests=5,
    room_type="single"
)
🧪 Password Confirm Validator
→ Enter passwords and click Validate...
🧠
Quick Check — 5.2 Advanced Validation
You want to validate that end_date > start_date. Which decorator is most appropriate?
A. @field_validator("end_date", mode="after")
B. @model_validator(mode="after")
C. @field_validator("start_date", mode="before")
D. Override __init__ manually
Topic 5 · Pydantic
5.3 — Serialization
Pydantic models aren't just for validation — they're also for converting data back to Python dicts, JSON, or other formats. This is called serialization.
💡 Analogy
Think of a Pydantic model like a translator: it takes raw JSON and turns it into a typed Python object (model_validate), and it can turn that object back into a plain dict or JSON (model_dump) for responses or storage.
5.3.1 model_dump — Model → Dict
📤
model_dump()

model_dump() converts a model instance to a plain Python dictionary. You can control which fields to include, exclude, or how to handle nested models.

python
from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    password: str
    email: str

u = User(id=1, name="Alice", password="secret", email="a@x.com")

# Full dump
print(u.model_dump())
# {'id': 1, 'name': 'Alice', 'password': 'secret', 'email': 'a@x.com'}

# Exclude sensitive fields
print(u.model_dump(exclude={"password"}))
# {'id': 1, 'name': 'Alice', 'email': 'a@x.com'}

# Include only specific fields
print(u.model_dump(include={"id", "name"}))
# {'id': 1, 'name': 'Alice'}

# Exclude fields with None values
print(u.model_dump(exclude_none=True))

# Exclude fields with default values
print(u.model_dump(exclude_defaults=True))
exclude
Set of field names to remove from the output
include
Only include these fields in output
exclude_none
Skip fields that are None
exclude_defaults
Skip fields still holding their default value
by_alias
Use field aliases in the output dict
5.3.2 model_validate — Dict/JSON → Model
📥
model_validate()

model_validate() is the class-level constructor that creates a model instance from a dictionary, ORM object, or any mapping. It's the replacement for Pydantic v1's parse_obj().

python
from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float
    in_stock: bool

# From a plain dict
data = {"name": "Laptop", "price": "999.99", "in_stock": "true"}
p = Product.model_validate(data)
print(p.price)     # 999.99 (float)
print(p.in_stock)  # True (bool)

# From JSON string
json_str = '{"name": "Phone", "price": 499, "in_stock": false}'
p2 = Product.model_validate_json(json_str)
print(p2.name)  # Phone

# From ORM object (SQLAlchemy row, etc.)
# Use model_config = ConfigDict(from_attributes=True)
from pydantic import ConfigDict

class ProductFromORM(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    name: str
    price: float

# orm_object is a SQLAlchemy model with .name and .price attributes
# product = ProductFromORM.model_validate(orm_object)
ℹ️
from_attributes=True is essential when integrating Pydantic with SQLAlchemy. It tells Pydantic to read from object attributes (like row.name) rather than dictionary keys.
5.3.3 model_copy — Clone & Update
📋
model_copy(update={})

Pydantic models are immutable by default. To "update" a model, you create a copy with some fields changed using model_copy(update=...). This is the Pydantic v2 equivalent of .copy(update=...).

python
from pydantic import BaseModel

class Config(BaseModel):
    host: str = "localhost"
    port: int = 5432
    db_name: str = "dev"

base_config = Config()
print(base_config)  # host='localhost' port=5432 db_name='dev'

# Create a copy with some fields updated
prod_config = base_config.model_copy(update={
    "host": "prod-db.internal",
    "db_name": "production"
})
print(prod_config)  # host='prod-db.internal' port=5432 db_name='production'
print(base_config)  # unchanged — original is intact

# Deep copy (copies nested models too)
test_config = base_config.model_copy(deep=True, update={"port": 5433})
This pattern is great for creating test variants of configurations or partial updates where you want to keep most fields unchanged. The original is never mutated.
🧠
Quick Check — 5.3 Serialization
You have a User model with a password field. You want to return the user to the client without exposing the password. What's the cleanest approach?
A. Manually build a new dict and delete the key
B. Use user.model_dump(exclude={"password"})
C. Set user.password = None before returning
D. Use json.dumps() after removing the field
Topic 5 · Pydantic
5.4 — Advanced Features
Pydantic's power goes beyond basic models. Aliases, computed fields, nested models, generics, and discriminated unions unlock complex real-world schemas.
5.4.1 Aliases
🏷️
Field Aliases — Mapping External Names to Python Names

External APIs often use camelCase or legacy names that don't match Python's snake_case convention. Pydantic aliases let you map between them cleanly.

python
from pydantic import BaseModel, Field, ConfigDict
from pydantic.alias_generators import to_camel

# Option 1: Per-field alias
class Payment(BaseModel):
    user_id: int = Field(alias="userId")        # input key is "userId"
    amount_cents: int = Field(alias="amountCents")

# Must use alias for input
p = Payment.model_validate({"userId": 42, "amountCents": 1000})
print(p.user_id)      # 42 (Python name)
print(p.model_dump(by_alias=True))
# {'userId': 42, 'amountCents': 1000}

# Option 2: Global alias generator (entire model)
class APIResponse(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    first_name: str
    last_name: str
    created_at: str

# Both work with populate_by_name=True:
r1 = APIResponse(first_name="John", last_name="Doe", created_at="2025")
r2 = APIResponse.model_validate({"firstName":"Jane","lastName":"Doe","createdAt":"2025"})
For FastAPI + JavaScript frontends, set alias_generator=to_camel globally on your response models to auto-convert Python's snake_case to JS-friendly camelCase.
5.4.2 Computed Fields
@computed_field — Derived Values in the Schema

A @computed_field is a read-only property that is included in model_dump() and the JSON schema. It derives its value from other fields — like a full name from first + last.

python
from pydantic import BaseModel, computed_field

class Rectangle(BaseModel):
    width: float
    height: float

    @computed_field
    @property
    def area(self) -> float:
        return self.width * self.height

    @computed_field
    @property
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

r = Rectangle(width=5.0, height=3.0)
print(r.area)       # 15.0
print(r.perimeter)  # 16.0
print(r.model_dump())
# {'width': 5.0, 'height': 3.0, 'area': 15.0, 'perimeter': 16.0}

# FastAPI use case: full_name derived from first + last
class UserProfile(BaseModel):
    first_name: str
    last_name: str

    @computed_field
    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"
5.4.3 Nested Models
🪆
Nesting Pydantic Models Inside Each Other

A Pydantic model field can itself be another Pydantic model. This lets you model complex, hierarchical data — like an Order containing a list of LineItem objects and an Address.

python
from pydantic import BaseModel
from typing import Optional

class Address(BaseModel):
    street: str
    city: str
    zip_code: str
    country: str = "IN"

class LineItem(BaseModel):
    product_id: int
    name: str
    quantity: int
    unit_price: float

    @computed_field
    @property
    def subtotal(self) -> float:
        return self.quantity * self.unit_price

class Order(BaseModel):
    order_id: str
    customer_name: str
    shipping_address: Address          # nested model
    items: list[LineItem]             # list of nested models
    notes: Optional[str] = None

# Pydantic recursively validates the nested dicts
order = Order.model_validate({
    "order_id": "ORD-001",
    "customer_name": "Alice",
    "shipping_address": {
        "street": "123 MG Road",
        "city": "Bengaluru",
        "zip_code": "560001"
    },
    "items": [
        {"product_id": 1, "name": "Laptop", "quantity": 1, "unit_price": 75000},
        {"product_id": 2, "name": "Mouse",  "quantity": 2, "unit_price": 500}
    ]
})
print(order.shipping_address.city)     # Bengaluru
print(order.items[0].name)             # Laptop
5.4.4 Generic Models
🧬
Generic Models — Reusable Wrappers

Generic models let you create reusable wrapper schemas — like a standard API response envelope that can wrap any data type. Extremely common in production FastAPI apps.

python
from pydantic import BaseModel
from typing import Generic, TypeVar, Optional

T = TypeVar("T")

class APIResponse(BaseModel, Generic[T]):
    """Standard API envelope used across all endpoints."""
    success: bool
    data: Optional[T] = None
    message: str = ""
    code: int = 200

# Use it with any type
class UserOut(BaseModel):
    id: int
    name: str

class ProductOut(BaseModel):
    id: int
    title: str

# FastAPI endpoint
from fastapi import FastAPI
app = FastAPI()

@app.get("/users/{user_id"}, response_model=APIResponse[UserOut])
async def get_user(user_id: int):
    user = UserOut(id=user_id, name="Alice")
    return APIResponse(success=True, data=user)

# Response JSON:
# {"success": true, "data": {"id": 1, "name": "Alice"}, "message": "", "code": 200}
ℹ️
Generic models are fully typed: FastAPI/OpenAPI will correctly display APIResponse[UserOut] in the docs, showing exactly what shape the data field has.
5.4.5 Discriminated Unions
🔀
Discriminated Unions — Polymorphic Payloads

A discriminated union lets Pydantic decide which model to use based on a specific discriminator field in the data. Think of it as a type switch — you send "type": "email" and Pydantic knows to use the EmailNotification model.

💡 Analogy
Like a parcel sorting machine — the type field on the box tells it which belt to send the package down.
python
from pydantic import BaseModel
from typing import Literal, Union, Annotated
from pydantic import Field

class EmailNotification(BaseModel):
    type: Literal["email"]        # discriminator value
    to: str
    subject: str
    body: str

class SMSNotification(BaseModel):
    type: Literal["sms"]          # discriminator value
    phone: str
    message: str

class PushNotification(BaseModel):
    type: Literal["push"]         # discriminator value
    device_token: str
    title: str
    body: str

# Annotated Union with discriminator
Notification = Annotated[
    Union[EmailNotification, SMSNotification, PushNotification],
    Field(discriminator="type")
]

class NotificationJob(BaseModel):
    notification: Notification
    priority: int = 1

# Pydantic picks the right model based on "type"
job1 = NotificationJob.model_validate({
    "notification": {"type": "email", "to": "a@x.com",
                      "subject": "Hi", "body": "Hello"}
})
print(type(job1.notification).__name__)  # EmailNotification

job2 = NotificationJob.model_validate({
    "notification": {"type": "sms", "phone": "+91999",
                      "message": "Your OTP is 1234"}
})
print(type(job2.notification).__name__)  # SMSNotification
Performance tip: Discriminated unions are much faster than regular Union types. With a discriminator, Pydantic doesn't try each model in turn — it jumps directly to the right one in O(1) time.
🧠
Quick Check — 5.4 Advanced Features
You're building a standard API envelope: {"success": true, "data": <any model>}. Which Pydantic feature fits best?
A. Discriminated Union — to handle different data types
B. Computed Field — to derive the response shape
C. Generic Model — APIResponse[T] wraps any model
D. Nested Model — nest all types inside one model
📚 Topic 5 — Pydantic Summary
BaseModel
Foundation of all Pydantic schemas — defines field types, defaults, constraints
Field()
Add metadata, gt/ge/lt/le constraints, regex patterns, aliases
@field_validator
Custom logic for one field — run before or after type coercion
@model_validator
Cross-field validation — access all fields at once
model_dump()
Model → dict, with include/exclude control
model_validate()
Dict/ORM → model, with full validation
model_copy()
Clone a model instance with some fields changed (immutable update pattern)
Aliases
Map external camelCase/legacy names to Python snake_case fields
@computed_field
Derived read-only properties included in serialization
Nested Models
Use Pydantic models as field types for hierarchical data
Generic Models
Reusable Response[T] wrappers with TypeVar
Discriminated Unions
Fast polymorphic parsing based on a Literal discriminator field