← Back to FastAPI map
FastAPI · Foundations

Pydantic & validation

Limits on each field, your own checking functions, per-model settings, and the handy built-in types.

pydantic v2

Overview

Pydantic is the validation layer behind every request and response. Version 2, with its Rust core, parses input into typed objects, converts compatible values ("42" becomes 42 by default), and reports every error at once rather than stopping at the first. Field() adds constraints, validators add custom rules, and model_config controls behaviour such as reading from ORM objects.

Key concepts

Field(...)
The ... means required. Constraints like ge, max_length, and pattern are enforced on input and shown in the docs.
Validators
@field_validator checks one field - mode="before" sees the raw input, "after" the converted value. @model_validator checks rules that span fields, such as matching passwords.
from_attributes=True
Lets a model read attributes from an object such as a SQLAlchemy row. It replaces v1's orm_mode.
Aliases
Accept "fullName" in JSON while the Python attribute stays full_name.
Discriminated unions
A Literal "type" field tells Pydantic which model in a union to use, instead of trying each in turn.

Best practices

  • Split schemas by purpose: Create for input, Update with all-optional fields for PATCH, Response for output without secrets.
  • Use precise types - EmailStr, UUID, Decimal for money, Literal for statuses - so most validation comes for free.
  • Raise ValueError inside validators; Pydantic turns it into a proper entry in the 422 response.

Pydantic v2 models

Field() - all constraints

Field
from pydantic import BaseModel, Field from typing import Optional class UserCreate(BaseModel): username: str = Field(..., min_length=3, max_length=20) email: str = Field(..., pattern=r"^[^@]+@[^@]+\.[^@]+$") password: str = Field(..., min_length=8, exclude=True) age: int = Field(..., ge=13, le=120) bio: Optional[str] = Field( None, max_length=500, title="Bio", description="Short user biography", examples=["Senior dev at RTE"] ) score: float = Field(0.0, ge=0, le=100) price: float = Field(..., gt=0, multiple_of=0.01)

@field_validator and @model_validator

Validators
from pydantic import BaseModel, field_validator, model_validator from typing import Self # Python 3.11+; on 3.10 import from typing_extensions class RegisterForm(BaseModel): username: str email: str password: str confirm_password: str age: int # Field-level validator - runs after type coercion @field_validator("username") @classmethod def username_valid(cls, v: str) -> str: if not v.isalnum(): raise ValueError("Username must be alphanumeric") return v.lower() # can transform the value # Multiple fields, mode="before" = runs before type coercion @field_validator("email", "username", mode="before") @classmethod def strip_whitespace(cls, v: str) -> str: return v.strip() # Model-level validator - access all fields at once @model_validator(mode="after") def passwords_match(self) -> Self: if self.password != self.confirm_password: raise ValueError("Passwords do not match") return self

model_config - ORM, aliases, extra

Config
from pydantic import BaseModel, ConfigDict, Field from typing import Optional class UserOut(BaseModel): model_config = ConfigDict( from_attributes=True, # allows ORM obj -> model extra="ignore", # ignore unknown JSON fields populate_by_name=True, # allow field name OR alias str_strip_whitespace=True,# auto-strip strings ) id: int full_name: str = Field(alias="fullName") # JSON uses camelCase # Schema patterns - avoid repeating fields class UserBase(BaseModel): email: str username: str class UserCreate(UserBase): password: str # only for creation class UserUpdate(BaseModel): email: Optional[str] = None # all optional for PATCH username: Optional[str] = None class UserResponse(UserBase): model_config = ConfigDict(from_attributes=True) id: int is_active: bool # no password exposed

Special Pydantic types

Types
from pydantic import BaseModel, EmailStr, AnyHttpUrl from pydantic.types import PositiveInt, NegativeFloat from typing import Literal from uuid import UUID from datetime import datetime, date from decimal import Decimal class Order(BaseModel): id: UUID # valid UUID email: EmailStr # pip install email-validator website: AnyHttpUrl # validated URL quantity: PositiveInt # int > 0 price: Decimal # exact decimal (not float) created: datetime # ISO 8601 birthday: date # date only status: Literal["pending", "paid", "shipped"]

Discriminated unions - polymorphic payloads

Advanced
from typing import Annotated, Literal, Union from pydantic import BaseModel, Field class EmailNotif(BaseModel): type: Literal["email"] to: str subject: str class SMSNotif(BaseModel): type: Literal["sms"] phone: str Notification = Annotated[ Union[EmailNotif, SMSNotif], Field(discriminator="type") ] @app.post("/notify") async def notify(notif: Notification): # FastAPI picks the model from the "type" field return notif

Tip: The discriminator is a field that says which model this is, so FastAPI picks that one straight away instead of trying each in turn - and errors point at the right model.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...