Skip to main content
Advertisement

Pydantic v2 Models

FastAPI uses Pydantic v2 to handle request validation, response serialization, and settings management. Pydantic v2 was rewritten in Rust and is 5–50x faster than v1.


BaseModel Basics

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


class User(BaseModel):
id: int
name: str
email: str
age: Optional[int] = None
created_at: datetime = Field(default_factory=datetime.now)

model_config = {
"str_strip_whitespace": True, # strip leading/trailing whitespace
"validate_assignment": True, # validate on assignment too
}


# Create
user = User(id=1, name=" Alice ", email="alice@example.com")
print(user.name) # "Alice" (whitespace stripped)
print(user.model_dump()) # convert to dict
print(user.model_dump_json()) # convert to JSON string

# Parse
data = {"id": "2", "name": "Bob", "email": "bob@test.com"}
user2 = User.model_validate(data) # auto-converts "2" → 2

Field — Field Validation

from pydantic import BaseModel, Field
from typing import Annotated


class Product(BaseModel):
# Required (no default)
name: str = Field(..., min_length=1, max_length=100, description="Product name")

# Numeric range
price: float = Field(..., gt=0, description="Price (greater than 0)")
stock: int = Field(0, ge=0, description="Stock (0 or more)")
discount: float = Field(0.0, ge=0.0, le=1.0, description="Discount rate 0~1")

# Regex validation
sku: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$", description="e.g. ABC-1234")

# Alias (different JSON key name)
product_type: str = Field(..., alias="type")

# Different name when serializing
internal_code: str = Field(..., serialization_alias="code")


# Using alias
data = {"name": "Python Book", "price": 35000, "sku": "PYT-0001", "type": "book", "code": "B001"}
product = Product.model_validate(data)
print(product.product_type) # "book"

# Serialize with alias
print(product.model_dump(by_alias=True))

field_validator — Custom Validation

from pydantic import BaseModel, Field, field_validator, model_validator
import re


class UserCreate(BaseModel):
username: str
email: str
password: str
confirm_password: str
age: int

# Single field validation
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must contain only letters and digits")
return v.lower()

@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email format")
return v.lower()

@field_validator("age")
@classmethod
def age_range(cls, v: int) -> int:
if not (0 <= v <= 150):
raise ValueError(f"Invalid age: {v}")
return v

# Validate multiple fields (model_validator)
@model_validator(mode="after")
def passwords_match(self) -> "UserCreate":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self


# ValidationError on failure
from pydantic import ValidationError

try:
user = UserCreate(
username="alice!!",
email="not-email",
password="secret",
confirm_password="different",
age=200,
)
except ValidationError as e:
for error in e.errors():
print(f"{error['loc']}: {error['msg']}")

Nested Models

from pydantic import BaseModel
from typing import Optional


class Address(BaseModel):
street: str
city: str
country: str = "KR"
zip_code: Optional[str] = None


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


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


# Parse nested structure
data = {
"id": 1,
"name": "Alice",
"company": {
"name": "Tech Corp",
"address": {
"street": "123 Main St",
"city": "Seoul",
},
},
"addresses": [
{"street": "Home address", "city": "Seoul"},
],
}

emp = Employee.model_validate(data)
print(emp.company.address.city) # Seoul

# Deep dict conversion
print(emp.model_dump())

Response Model Patterns

from fastapi import FastAPI
from pydantic import BaseModel, Field
from datetime import datetime

app = FastAPI()


# Separate request/response models
class UserCreate(BaseModel):
"""Create request model (includes password)"""
name: str
email: str
password: str


class UserUpdate(BaseModel):
"""Update request model (all fields optional)"""
name: str | None = None
email: str | None = None


class UserResponse(BaseModel):
"""Response model (excludes password)"""
id: int
name: str
email: str
created_at: datetime

model_config = {"from_attributes": True} # auto-convert ORM objects


# response_model_exclude_none: exclude None fields
class PartialResponse(BaseModel):
id: int
name: str
score: float | None = None
rank: int | None = None


@app.get(
"/users/{user_id}",
response_model=PartialResponse,
response_model_exclude_none=True, # don't include None in JSON
)
def get_user(user_id: int):
return PartialResponse(id=user_id, name="Alice", score=None, rank=None)
# Response: {"id": 1, "name": "Alice"} (score, rank excluded)

Summary

FeatureHow
Basic validationField(min_length=1, gt=0, pattern=...)
Custom validation@field_validator("field")
Whole-model validation@model_validator(mode="after")
ORM conversionmodel_config = {"from_attributes": True}
To dictmodel.model_dump()
To JSONmodel.model_dump_json()
ParseModel.model_validate(data)
Exclude Noneresponse_model_exclude_none=True

Pydantic v2 is the core layer that unifies validation, serialization, and documentation in FastAPI.

Advertisement