---
title: "FastAPI: Pydantic v2 Models"
slug: "fastapi-pydantic"
category: "backend"
tags: ["backend", "fastapi", "pydantic", "python", "validation", "serialization"]
status: "stable"
last_updated: 2026-05-14
summary: "Pydantic v2 model design for FastAPI: separate read/write models, field validators, aliases, and nested composition."
related: ["[[backend/fastapi]]", "[[coding/python]]", "[[coding/python-typing]]", "[[backend/fastapi-dependencies]]", "[[backend/fastapi-openapi]]", "[[backend/postgres]]", "[[coding/general-principles]]"]
---

## Overview

Pydantic v2 is the validation and serialization layer that FastAPI builds on. Every request body, response body, and query parameter set passes through a Pydantic model. Getting model design right prevents a category of bugs where internal state leaks to clients, client-supplied values corrupt server-side fields, or validation runs redundantly across layers. This page extends the model guidance in [[backend/fastapi]] with Pydantic-specific patterns. See [[coding/python-typing]] for typing conventions used throughout.

## Use separate models for input and output

One model per data direction. The model that accepts input is not the model that returns output.

```python
from pydantic import BaseModel, EmailStr
from datetime import datetime

class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserRead(BaseModel):
    id: str
    email: EmailStr
    created_at: datetime

    model_config = {"from_attributes": True}
```

Write models (`UserCreate`, `OrderUpdate`) accept exactly what the caller may supply. Response models (`UserRead`) expose exactly what the caller may see. Fields like `password_hash`, `internal_status`, and `role_flags` belong on neither. Set `model_config = {"from_attributes": True}` on response models so they can be constructed from ORM instances. Register both in the router via `response_model=UserRead` rather than returning dicts.

## Validate at the edge, not in every layer

Run Pydantic validation once, at the HTTP boundary. Do not re-validate the same data in the service layer, the repository layer, or the database helper. Pass typed model instances or plain Python dataclasses between layers; re-validating adds overhead and masks the origin of bad data.

```python
@router.post("/users", response_model=UserRead, status_code=201)
async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)) -> UserRead:
    user = await user_service.create(db, payload)  # payload is already validated
    return UserRead.model_validate(user)
```

See [[backend/fastapi-dependencies]] for passing validated payloads through the dependency chain.

## Use field validators for business rules, not type coercion

`@field_validator` runs after Pydantic has coerced the raw value to the annotated type. Use it for business rules that go beyond type correctness.

```python
from pydantic import BaseModel, field_validator

class OrderCreate(BaseModel):
    quantity: int
    unit_price: float

    @field_validator("quantity")
    @classmethod
    def quantity_must_be_positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("quantity must be positive")
        return v
```

Avoid validators that duplicate type annotation work, such as checking `isinstance(v, str)` when the field is already `str`. Use `@model_validator(mode="after")` for cross-field validation where one field constrains another.

## Prefer aliases for external names that differ from Python conventions

API consumers often expect `camelCase` or hyphenated field names. Use `alias` or `alias_generator` rather than naming Python attributes after external conventions.

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

class InvoiceRead(BaseModel):
    invoice_id: str
    line_item_count: int
    total_amount_cents: int

    model_config = {
        "alias_generator": to_camel,
        "populate_by_name": True,
    }
```

Set `populate_by_name: True` so that internal code can still use snake_case attribute names. The FastAPI route will serialize responses using the alias automatically. Explicitly set `by_alias=True` if you call `model.model_dump()` manually.

## Compose nested models for structured payloads

Model nested structures as separate classes rather than dicts or `Any`. Nesting keeps schemas focused and lets OpenAPI render the structure correctly. See [[backend/fastapi-openapi]] for how nested models appear in the generated schema.

```python
class Address(BaseModel):
    street: str
    city: str
    postal_code: str

class CustomerCreate(BaseModel):
    name: str
    email: EmailStr
    billing_address: Address
    shipping_address: Address | None = None
```

Keep individual models under about ten fields. When a model grows beyond that, split it into sub-models that each capture a coherent domain concept. The [[backend/postgres]] schema should guide the nesting: models that mirror table relationships are easier to validate and serialize.

## Use `model_json_schema` and `model_validate_json` for raw JSON paths

When reading JSON from a message queue, a file, or a webhook without FastAPI's automatic parsing, use Pydantic's own methods rather than `json.loads` followed by manual construction.

```python
import json
from pydantic import BaseModel

class WebhookEvent(BaseModel):
    event_type: str
    payload: dict[str, object]

raw = b'{"event_type": "payment.success", "payload": {"amount": 500}}'
event = WebhookEvent.model_validate_json(raw)
```

`model_validate_json` is faster than `model_validate(json.loads(raw))` because it parses and validates in one pass. Use `model_json_schema()` to export the schema for documentation or contract testing without spinning up the full FastAPI application.

## Related

- [[backend/fastapi]]
- [[coding/python]]
- [[coding/python-typing]]
- [[backend/fastapi-dependencies]]
- [[backend/fastapi-openapi]]
- [[backend/postgres]]
- [[coding/general-principles]]
