🟩 FastAPI

Pydantic & Validation

📖 Lesson 38 ⏱ 45 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Define Pydantic v2 models with typed fields, defaults, and Field() constraints
  • Use built-in validators: EmailStr, AnyUrl, constr, conint, confloat
  • Write @field_validator and @model_validator for custom validation logic
  • Understand Pydantic's coercion rules and strict mode
  • Use model_dump(), model_dump_json(), model_validate(), and model_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 + typed
comparison.py
Concept: Pydantic raises ValidationError 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 only
basemodel_basics.py

The Field() function accepts many constraint parameters:

Parameter Type Purpose
gtnumericGreater than
genumericGreater than or equal
ltnumericLess than
lenumericLess than or equal
min_lengthintMinimum string/list length
max_lengthintMaximum string/list length
patternstr (regex)String must match regex
descriptionstrHuman-readable description (shows in JSON Schema)
defaultanyDefault value
default_factorycallableFactory for mutable defaults
aliasstrAlternate name for parsing input
reprboolInclude in __repr__ output
excludeboolExclude 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] = None
builtin_types.py

EmailStr 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: PositivePrice
annotated_types.py
Tip: Define reusable Annotated 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 v
field_validators.py

mode="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 self
model_validators_after.py

mode="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 5432
model_validators_before.py
Warning: In mode="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 caller
parsing_serialisation.py
Concept: exclude_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)  # 42
nested_unions.py

Discriminated 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 discriminator mapping.

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

OptionDefaultEffect
strictFalseDisable all type coercion — values must already be the correct type
frozenFalseMake instances immutable; enables hashing
extra"ignore""allow" stores extras, "forbid" raises, "ignore" silently drops
populate_by_nameFalseAccept both the field name and its alias during parsing
str_strip_whitespaceFalseStrip leading/trailing whitespace from all str fields
str_to_lowerFalseLowercase all str fields after parsing
use_enum_valuesFalseStore the enum's value rather than the enum member
validate_defaultFalseRun validators on default values too
from_attributesFalseAllow building models from ORM objects (model.attr instead of dict)
ORM integration: set 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
Never hardcode secrets in 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 modelsUserCreate (has password), UserOut (no password), UserUpdate (all Optional). Never reuse one model for all three roles.
  • Use Annotated type 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_validator for single-field rules.
  • Use exclude_unset=True in 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=True when integrating with ORMs — lets you call UserOut.model_validate(db_row) without a manual conversion layer.
  • Use pydantic-settings for all configuration — typed, validated, .env-aware config beats os.getenv() scattered through your codebase.
  • Validators should raise ValueError (or AssertionError) — Pydantic wraps them into ValidationError automatically 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") on UserCreate that raises if username is a substring of password (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 NotificationRequest model with a notification field using a discriminated union on type.
  • 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 Settings model with: app_name: str, debug: bool = False, database_url: AnyUrl, api_key: str = Field(min_length=20).
  • Load from a .env file using SettingsConfigDict(env_file=".env").
  • Cache the settings with @lru_cache and expose via Depends(get_settings).
  • Add a GET /config route that returns app_name and debug (never expose api_key or database_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()