Chapter 3: Pydantic v2 as FastAPI’s validation engine. We cover the BaseModel, fields, validators, error formatting, and the patterns that make request validation safe and ergonomic. For Pydantic itself in depth, see the Pydantic v2 Textbook .

BaseModel basics

from pydantic import BaseModel, Field, EmailStr

class UserCreate(BaseModel):
    email: EmailStr
    full_name: str = Field(..., min_length=1, max_length=120)
    age: int | None = Field(default=None, ge=0, le=150)
    tags: list[str] = Field(default_factory=list, max_length=10)

Use this in a route:

@app.post("/users")
async def create_user(user: UserCreate):
    return {"name": user.full_name, "email": user.email}

FastAPI reads JSON, validates against UserCreate. Bad input → 422 with details.

Field

Field adds metadata:

  • default / default_factory.
  • Validation: min_length, max_length, ge, gt, le, lt, pattern.
  • Documentation: description, examples, title.
  • Aliasing: alias, validation_alias, serialization_alias.
class Item(BaseModel):
    sku: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$", description="SKU like ABC-1234")
    price_cents: int = Field(..., ge=0, description="Price in cents")

Validators

from pydantic import field_validator, model_validator

class Order(BaseModel):
    quantity: int
    unit_price: float
    
    @field_validator("quantity")
    @classmethod
    def quantity_positive(cls, v):
        if v <= 0:
            raise ValueError("quantity must be positive")
        return v
    
    @model_validator(mode="after")
    def total_under_limit(self):
        if self.quantity * self.unit_price > 1_000_000:
            raise ValueError("order too large")
        return self

field_validator for single fields; model_validator for cross-field. mode="before" runs before parsing; mode="after" runs after.

Type system

Pydantic supports a wide range of types:

  • Primitives: int, float, str, bool, bytes.
  • Collections: list, dict, tuple, set, frozenset.
  • Standard library: datetime, date, time, timedelta, Decimal, UUID, Path.
  • Networking: EmailStr, IPvAnyAddress, HttpUrl, AnyUrl.
  • Constrained: conint, confloat, constr, conlist.
  • Generic: TypeVar-based generics.
  • Discriminated unions.
  • Annotated types.

For richer constrained types, prefer Annotated:

from typing import Annotated
from pydantic import StringConstraints

Username = Annotated[str, StringConstraints(min_length=3, max_length=32, pattern=r"^[a-z0-9_]+$")]

class UserCreate(BaseModel):
    username: Username

Reusable; composable.

Optional vs nullable

class M(BaseModel):
    a: str | None              # nullable, but required
    b: str | None = None       # nullable AND optional (defaults to None)
    c: str = "default"         # required-with-default

Subtle. Match exactly what the JSON contract says.

Aliases

When request JSON keys don’t match Python conventions:

class User(BaseModel):
    full_name: str = Field(alias="fullName")
    
    model_config = {"populate_by_name": True}

JSON {"fullName": "Alice"}User(full_name="Alice"). With populate_by_name=True, both forms work.

For separate validation vs serialization aliases:

class User(BaseModel):
    user_id: int = Field(validation_alias="id", serialization_alias="userId")

Common when adapting external schemas.

Nested models

class Address(BaseModel):
    street: str
    city: str
    country: str

class UserCreate(BaseModel):
    name: str
    addresses: list[Address]

Deep validation. Errors point to the failing path.

Discriminated unions

from typing import Literal, Annotated
from pydantic import Field

class Cat(BaseModel):
    kind: Literal["cat"]
    meows_per_day: int

class Dog(BaseModel):
    kind: Literal["dog"]
    barks_per_day: int

Animal = Annotated[Cat | Dog, Field(discriminator="kind")]

class Pet(BaseModel):
    name: str
    animal: Animal

Pydantic uses kind to dispatch. Faster + clearer errors than untagged unions.

Validation errors

FastAPI converts Pydantic errors to 422:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "email"],
      "msg": "Field required",
      "input": {...}
    }
  ]
}

The loc array tells you where: body, query, path, header, cookie, then the field path.

Custom error responses

from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def custom_validation_exception_handler(request, exc):
    return JSONResponse(
        status_code=400,
        content={
            "error": "validation_error",
            "details": [{"field": ".".join(map(str, e["loc"][1:])), "message": e["msg"]} for e in exc.errors()],
        },
    )

Override the default 422 format if you need a different envelope.

Strict mode

By default, Pydantic v2 is fairly forgiving (string "5"int(5)). For stricter:

class M(BaseModel):
    model_config = {"strict": True}
    
    age: int  # now requires actual int, not "5"

Or per field:

from pydantic import StrictInt

class M(BaseModel):
    age: StrictInt

Strict mode catches more class of bugs at the API boundary.

Computed fields

from pydantic import computed_field

class User(BaseModel):
    first: str
    last: str
    
    @computed_field
    @property
    def full(self) -> str:
        return f"{self.first} {self.last}"

Serialized; not accepted on input.

Settings

For app config:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str
    secret_key: str
    
    model_config = {"env_file": ".env", "env_prefix": "MYAPP_"}

Environment vars become attributes. Used widely as a Depends.

Examples in docs

class UserCreate(BaseModel):
    email: EmailStr
    age: int = Field(ge=0)
    
    model_config = {
        "json_schema_extra": {
            "examples": [
                {"email": "[email protected]", "age": 30},
                {"email": "[email protected]", "age": 25},
            ]
        }
    }

Surfaces in Swagger UI as ready-to-try examples.

Forbid extra fields

class M(BaseModel):
    model_config = {"extra": "forbid"}
    
    name: str

Unknown keys → validation error. Useful for strict APIs.

Default is "ignore". Other option: "allow" (extras stored on the model).

Frozen / immutable

class M(BaseModel):
    model_config = {"frozen": True}
    
    name: str

m = M(name="x")
m.name = "y"  # ValidationError

For value-object semantics.

JSON schema

schema = UserCreate.model_json_schema()

Returns the JSON schema. FastAPI uses this to build OpenAPI.

Performance

Pydantic v2 is Rust-cored and fast. For most APIs, validation isn’t the bottleneck. If profiling shows it is:

  • Avoid huge unions without discriminator.
  • Cache Pydantic models at module scope.
  • Consider msgspec for ultra-hot paths (different ergonomics; smaller ecosystem).

Re-using models

For Create / Update / Read shapes:

class UserBase(BaseModel):
    email: EmailStr
    full_name: str

class UserCreate(UserBase):
    password: str

class UserUpdate(BaseModel):
    email: EmailStr | None = None
    full_name: str | None = None

class UserRead(UserBase):
    id: int
    created_at: datetime

Inheritance for the common fields. Distinct shapes for input vs output.

Common mistakes

1. Returning a SQLAlchemy model directly

Sometimes works (with from_attributes=True). Often leaks fields. Use a Read Pydantic model and let FastAPI serialize via response_model.

2. Validators that mutate without returning

@field_validator("name")
@classmethod
def lowercase(cls, v):
    v.lower()  # bug: doesn't return!

Always return.

3. Cross-field validation in field_validator

Use model_validator for cross-field. field_validator doesn’t see other fields reliably.

4. Forgetting populate_by_name

When using aliases, your code expects model.full_name but JSON has fullName. With default config, Python name doesn’t work for input. Set populate_by_name=True for both.

5. Sharing models across input and DB

Pydantic models for I/O; SQLAlchemy models for DB. Don’t conflate.

What’s next

Chapter 4 covers response models and serialization in depth.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .