validation-serialization.md 7.3 KB

Pydantic v2 Validation & Serialization

Modern validation patterns for FastAPI with Pydantic v2.

Basic Models

from pydantic import BaseModel, Field, EmailStr
from datetime import datetime
from typing import Annotated

class UserCreate(BaseModel):
    """Request model with field validation."""
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    age: int = Field(..., ge=0, le=150)
    bio: str | None = Field(default=None, max_length=500)

class UserResponse(BaseModel):
    """Response model with ORM support."""
    id: int
    name: str
    email: EmailStr
    created_at: datetime

    model_config = {"from_attributes": True}

Custom Validators

from pydantic import BaseModel, field_validator, model_validator
from typing import Self

class UserCreate(BaseModel):
    username: str
    password: str
    password_confirm: str

    @field_validator("username")
    @classmethod
    def validate_username(cls, v: str) -> str:
        """Validate single field."""
        if not v.isalnum():
            raise ValueError("Username must be alphanumeric")
        return v.lower()

    @model_validator(mode="after")
    def validate_passwords(self) -> Self:
        """Validate across multiple fields."""
        if self.password != self.password_confirm:
            raise ValueError("Passwords don't match")
        return self


# Before validation (raw input)
class Config(BaseModel):
    port: int

    @field_validator("port", mode="before")
    @classmethod
    def parse_port(cls, v):
        """Convert string to int before validation."""
        if isinstance(v, str):
            return int(v)
        return v

Computed Fields

from pydantic import BaseModel, computed_field
from datetime import datetime

class User(BaseModel):
    first_name: str
    last_name: str
    birth_date: datetime

    @computed_field
    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

    @computed_field
    @property
    def age(self) -> int:
        today = datetime.now()
        return today.year - self.birth_date.year

Field Serialization

from pydantic import BaseModel, field_serializer
from datetime import datetime
from decimal import Decimal

class Order(BaseModel):
    id: int
    total: Decimal
    created_at: datetime

    @field_serializer("total")
    def serialize_total(self, value: Decimal) -> str:
        """Serialize Decimal as formatted string."""
        return f"${value:.2f}"

    @field_serializer("created_at")
    def serialize_date(self, value: datetime) -> str:
        """Serialize datetime as ISO string."""
        return value.isoformat()


# Or use Annotated with serialization
from pydantic import PlainSerializer

FormattedDecimal = Annotated[
    Decimal,
    PlainSerializer(lambda v: f"${v:.2f}", return_type=str)
]

class Order(BaseModel):
    total: FormattedDecimal

Custom Types

from pydantic import BaseModel, GetCoreSchemaHandler
from pydantic_core import CoreSchema, core_schema
from typing import Any

class PhoneNumber(str):
    """Custom phone number type with validation."""

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        return core_schema.no_info_after_validator_function(
            cls._validate,
            core_schema.str_schema(),
        )

    @classmethod
    def _validate(cls, v: str) -> "PhoneNumber":
        # Remove non-digits
        digits = "".join(c for c in v if c.isdigit())
        if len(digits) != 10:
            raise ValueError("Phone must be 10 digits")
        return cls(f"({digits[:3]}) {digits[3:6]}-{digits[6:]}")


class Contact(BaseModel):
    name: str
    phone: PhoneNumber

# Usage
contact = Contact(name="John", phone="1234567890")
print(contact.phone)  # (123) 456-7890

Nested Models

from pydantic import BaseModel
from datetime import datetime

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

class Company(BaseModel):
    name: str
    address: Address

class UserResponse(BaseModel):
    id: int
    name: str
    company: Company | None = None
    addresses: list[Address] = []

    model_config = {"from_attributes": True}

Discriminated Unions

from pydantic import BaseModel, Field
from typing import Literal, Union
from typing_extensions import Annotated

class Dog(BaseModel):
    pet_type: Literal["dog"]
    name: str
    breed: str

class Cat(BaseModel):
    pet_type: Literal["cat"]
    name: str
    indoor: bool = True

# Use discriminator for efficient parsing
Pet = Annotated[
    Union[Dog, Cat],
    Field(discriminator="pet_type")
]

class Owner(BaseModel):
    name: str
    pets: list[Pet]

# FastAPI automatically validates
@app.post("/owners")
async def create_owner(owner: Owner):
    return owner

Model Inheritance

from pydantic import BaseModel
from datetime import datetime

class BaseResponse(BaseModel):
    """Base for all responses."""
    model_config = {"from_attributes": True}

class TimestampMixin(BaseModel):
    """Mixin for timestamp fields."""
    created_at: datetime
    updated_at: datetime

class UserBase(BaseModel):
    name: str
    email: str

class UserCreate(UserBase):
    password: str

class UserResponse(UserBase, TimestampMixin, BaseResponse):
    id: int

Partial Updates (PATCH)

from pydantic import BaseModel
from typing import Any

class UserUpdate(BaseModel):
    """All fields optional for partial updates."""
    name: str | None = None
    email: str | None = None
    bio: str | None = None

@app.patch("/users/{user_id}")
async def update_user(user_id: int, updates: UserUpdate):
    # Only get set fields
    update_data = updates.model_dump(exclude_unset=True)

    # Apply to existing user
    user = await get_user(user_id)
    for field, value in update_data.items():
        setattr(user, field, value)

    return user

Validation Error Handling

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

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError
):
    """Custom validation error response."""
    errors = []
    for error in exc.errors():
        errors.append({
            "field": ".".join(str(loc) for loc in error["loc"]),
            "message": error["msg"],
            "type": error["type"],
        })

    return JSONResponse(
        status_code=422,
        content={
            "detail": "Validation error",
            "errors": errors,
        },
    )

Quick Reference

Feature Pydantic v2
ORM mode model_config = {"from_attributes": True}
Field validator @field_validator("field")
Model validator @model_validator(mode="after")
Serializer @field_serializer("field")
Computed @computed_field + @property
Exclude unset model_dump(exclude_unset=True)
Discriminator Field(discriminator="type")
Validation Usage
Required name: str
Optional name: str \| None = None
Default name: str = "default"
Constraints Field(min_length=1, max_length=100)
Custom @field_validator