Mastering Pydantic in FastAPI: Data Validation Made Easy

Pydantic is the secret weapon that makes FastAPI so powerful. It handles data validation, serialization, and type conversion automatically, ensuring your API receives and returns clean, validated data. Let's explore how to leverage Pydantic effectively in your FastAPI applications.
What is Pydantic?
Pydantic is a data validation library that uses Python type hints to validate data. It's the foundation of FastAPI's automatic request validation, response serialization, and OpenAPI documentation generation.
Why Pydantic + FastAPI is Perfect:
- Automatic Validation - Invalid data never reaches your code
- Type Safety - Full IDE autocomplete and type checking
- Serialization - Convert complex types to JSON automatically
- Documentation - Generate OpenAPI schemas from models
- Performance - Fast validation using Rust (pydantic v2)
- Developer Experience - Write less code, get more features
Basic Pydantic Models
Simple Model
from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
age: int
is_active: bool = True # Default value
# Usage
user = User(name="John Doe", email="john@example.com", age=30)
print(user.name) # "John Doe"
print(user.model_dump()) # Dict representationWhat Pydantic Does:
- Validates types automatically
- Converts compatible types (e.g., "30" → 30)
- Raises ValidationError for invalid data
- Provides JSON serialization
FastAPI Integration
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class User(BaseModel):
name: str
email: str
age: int
@app.post("/users/")
async def create_user(user: User):
# user is already validated and typed
return {"message": f"Created user {user.name}"}Try this invalid request:
{
"name": "John",
"email": "not-an-email",
"age": "invalid"
}FastAPI returns:
{
"detail": [
{
"loc": ["body", "age"],
"msg": "Input should be a valid integer",
"type": "int_parsing"
}
]
}Field Validation
Field Constraints
from pydantic import BaseModel, Field
class User(BaseModel):
username: str = Field(
..., # Required field
min_length=3,
max_length=50,
pattern="^[a-zA-Z0-9_]+$",
description="Alphanumeric username"
)
age: int = Field(..., ge=0, le=150, description="Age in years")
email: str = Field(..., description="Valid email address")
bio: str | None = Field(None, max_length=500)Available Constraints:
For Strings:
min_length/max_length- Length limitspattern- Regex patternstrip_whitespace- Remove leading/trailing whitespace
For Numbers:
gt/ge- Greater than / greater or equallt/le- Less than / less or equalmultiple_of- Must be multiple of value
For Collections:
min_length/max_length- Collection sizeunique_items- All items must be unique
Built-in Validators
from pydantic import BaseModel, EmailStr, HttpUrl, UUID4
from datetime import datetime
class UserProfile(BaseModel):
id: UUID4 # UUID validation
email: EmailStr # Email format validation
website: HttpUrl # URL validation
created_at: datetime # ISO datetime parsing
updated_at: datetime | None = NoneNote: Some validators require extra dependencies:
pip install "pydantic[email]" # For EmailStrCustom Field Examples
from pydantic import BaseModel, Field
from typing import List
class Product(BaseModel):
name: str = Field(..., examples=["Laptop Pro 15"])
price: float = Field(..., gt=0, examples=[999.99])
tags: List[str] = Field(
default_factory=list,
max_length=5,
examples=[["electronics", "computers"]]
)
discount: float = Field(
0.0,
ge=0.0,
le=1.0,
description="Discount as decimal (0.1 = 10%)"
)Custom Validators
Field Validators
from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
email: str
password: str
password_confirm: str
@field_validator('username')
@classmethod
def username_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError('Username must be alphanumeric')
return v
@field_validator('email')
@classmethod
def email_lowercase(cls, v):
return v.lower()
@field_validator('password')
@classmethod
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not any(c.isupper() for c in v):
raise ValueError('Password must contain uppercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain digit')
return vModel Validators
from pydantic import BaseModel, model_validator
class User(BaseModel):
password: str
password_confirm: str
age: int
parent_consent: bool = False
@model_validator(mode='after')
def check_passwords_match(self):
if self.password != self.password_confirm:
raise ValueError('Passwords do not match')
return self
@model_validator(mode='after')
def check_age_consent(self):
if self.age < 18 and not self.parent_consent:
raise ValueError('Minors require parental consent')
return selfBefore and After Validators
from pydantic import BaseModel, field_validator, model_validator
class Article(BaseModel):
title: str
content: str
tags: list[str]
# Before validator - runs before type conversion
@field_validator('title', mode='before')
@classmethod
def strip_title(cls, v):
if isinstance(v, str):
return v.strip()
return v
# After validator - runs after type conversion and validation
@field_validator('tags', mode='after')
@classmethod
def lowercase_tags(cls, v):
return [tag.lower() for tag in v]
# Model validator - access to all fields
@model_validator(mode='after')
def check_content_length(self):
if len(self.content) < 100:
raise ValueError('Article content too short')
return selfNested Models
Basic Nesting
from pydantic import BaseModel
from typing import List
class Address(BaseModel):
street: str
city: str
country: str
postal_code: str
class User(BaseModel):
name: str
email: str
address: Address # Nested model
# Usage
user = User(
name="John Doe",
email="john@example.com",
address={
"street": "123 Main St",
"city": "New York",
"country": "USA",
"postal_code": "10001"
}
)Lists of Nested Models
class OrderItem(BaseModel):
product_id: int
quantity: int
price: float
class Order(BaseModel):
order_id: int
items: List[OrderItem] # List of nested models
total: float
# Usage
order = Order(
order_id=1,
items=[
{"product_id": 1, "quantity": 2, "price": 10.0},
{"product_id": 2, "quantity": 1, "price": 20.0}
],
total=40.0
)Deeply Nested Models
from typing import List, Optional
class Comment(BaseModel):
id: int
text: str
author: str
class Post(BaseModel):
id: int
title: str
content: str
comments: List[Comment] = []
class Blog(BaseModel):
name: str
description: str
posts: List[Post]
owner: User
# Complex nested structure validated automaticallyOptional and Union Types
Optional Fields
from pydantic import BaseModel
from typing import Optional
class User(BaseModel):
name: str
email: str
phone: Optional[str] = None # Can be None
bio: str | None = None # Python 3.10+ syntaxUnion Types
from pydantic import BaseModel, Field
from typing import Union
class SuccessResponse(BaseModel):
status: str = "success"
data: dict
class ErrorResponse(BaseModel):
status: str = "error"
message: str
code: int
# Union type - can be either
Response = Union[SuccessResponse, ErrorResponse]
@app.get("/data/")
async def get_data() -> Response:
if some_condition:
return SuccessResponse(data={"key": "value"})
else:
return ErrorResponse(message="Error occurred", code=500)Discriminated Unions
from pydantic import BaseModel, Field
from typing import Literal, Union
class Cat(BaseModel):
pet_type: Literal["cat"]
meow_volume: int
class Dog(BaseModel):
pet_type: Literal["dog"]
bark_volume: int
class Pet(BaseModel):
pet: Union[Cat, Dog] = Field(..., discriminator='pet_type')
# Pydantic uses pet_type to determine which model to use
cat = Pet(pet={"pet_type": "cat", "meow_volume": 5})
dog = Pet(pet={"pet_type": "dog", "bark_volume": 10})Model Configuration
Model Config
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # Strip whitespace from strings
str_to_lower=True, # Convert strings to lowercase
validate_assignment=True, # Validate on assignment
validate_default=True, # Validate default values
extra='forbid', # Forbid extra fields
frozen=False, # Allow mutation
)
name: str
email: strExtra Fields Handling
# Allow extra fields
class FlexibleModel(BaseModel):
model_config = ConfigDict(extra='allow')
name: str
# Ignore extra fields (default)
class IgnoreExtraModel(BaseModel):
model_config = ConfigDict(extra='ignore')
name: str
# Forbid extra fields
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str
# Test
FlexibleModel(name="John", age=30) # OK, stores age
IgnoreExtraModel(name="John", age=30) # OK, ignores age
StrictModel(name="John", age=30) # ValidationErrorImmutable Models
from pydantic import BaseModel, ConfigDict
class ImmutableUser(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
name: str
user = ImmutableUser(id=1, name="John")
user.name = "Jane" # ValidationError: Instance is frozenSerialization and Deserialization
Model Dump
from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
password: str
user = User(name="John", email="john@example.com", password="secret")
# Convert to dict
user_dict = user.model_dump()
# Exclude fields
public_data = user.model_dump(exclude={'password'})
# Include only specific fields
minimal = user.model_dump(include={'name', 'email'})
# Convert to JSON string
json_str = user.model_dump_json()
# Exclude unset fields
user_partial = User(name="John")
user_partial.model_dump(exclude_unset=True) # Only {'name': 'John'}Response Models in FastAPI
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: str
class UserOut(BaseModel):
username: str
email: str
# Password not included!
@app.post("/users/", response_model=UserOut)
async def create_user(user: UserIn):
# Return UserIn, but FastAPI serializes as UserOut
return user # Password automatically excludedCustom Serialization
from pydantic import BaseModel, field_serializer
from datetime import datetime
class Event(BaseModel):
name: str
timestamp: datetime
@field_serializer('timestamp')
def serialize_timestamp(self, value: datetime):
return value.isoformat()
event = Event(name="Meeting", timestamp=datetime.now())
print(event.model_dump_json())
# {"name": "Meeting", "timestamp": "2024-01-20T10:30:00"}Model Inheritance
Basic Inheritance
from pydantic import BaseModel
class BaseUser(BaseModel):
email: str
username: str
class AdminUser(BaseUser):
is_admin: bool = True
permissions: list[str]
class RegularUser(BaseUser):
is_admin: bool = False
# AdminUser has email, username, is_admin, and permissions
admin = AdminUser(
email="admin@example.com",
username="admin",
permissions=["read", "write", "delete"]
)Composition Over Inheritance
from pydantic import BaseModel
class Timestamps(BaseModel):
created_at: datetime
updated_at: datetime | None = None
class AuditInfo(BaseModel):
created_by: str
updated_by: str | None = None
class Post(BaseModel):
title: str
content: str
timestamps: Timestamps
audit: AuditInfo
# Compose models instead of deep inheritanceSettings and Configuration
Application Settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file='.env',
env_file_encoding='utf-8',
case_sensitive=False
)
# Database
database_url: str
database_pool_size: int = 5
# API Keys
api_key: str
secret_key: str
# Features
debug: bool = False
log_level: str = "INFO"
# Load from environment variables and .env file
settings = Settings()Using Settings in FastAPI
from fastapi import FastAPI, Depends
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
app_name: str = "My API"
database_url: str
secret_key: str
@lru_cache()
def get_settings():
return Settings()
app = FastAPI()
@app.get("/info/")
async def info(settings: Settings = Depends(get_settings)):
return {"app_name": settings.app_name}Real-World Example: E-commerce Models
from pydantic import BaseModel, Field, field_validator, EmailStr
from typing import List, Optional
from datetime import datetime
from enum import Enum
# Enums
class OrderStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class PaymentMethod(str, Enum):
CREDIT_CARD = "credit_card"
PAYPAL = "paypal"
BANK_TRANSFER = "bank_transfer"
# Base Models
class TimestampMixin(BaseModel):
created_at: datetime = Field(default_factory=datetime.now)
updated_at: Optional[datetime] = None
# Product Models
class ProductBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., max_length=2000)
price: float = Field(..., gt=0, description="Price in USD")
stock: int = Field(..., ge=0)
@field_validator('price')
@classmethod
def round_price(cls, v):
return round(v, 2)
class ProductCreate(ProductBase):
pass
class Product(ProductBase, TimestampMixin):
id: int
sku: str
is_active: bool = True
# Order Models
class OrderItem(BaseModel):
product_id: int
quantity: int = Field(..., ge=1)
unit_price: float = Field(..., gt=0)
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
class ShippingAddress(BaseModel):
recipient_name: str
street: str
city: str
state: str
postal_code: str
country: str
phone: str
class OrderBase(BaseModel):
items: List[OrderItem] = Field(..., min_length=1)
shipping_address: ShippingAddress
payment_method: PaymentMethod
@field_validator('items')
@classmethod
def validate_items(cls, v):
if not v:
raise ValueError('Order must have at least one item')
return v
@property
def total_amount(self) -> float:
return sum(item.subtotal for item in self.items)
class OrderCreate(OrderBase):
pass
class Order(OrderBase, TimestampMixin):
id: int
user_id: int
status: OrderStatus = OrderStatus.PENDING
total: float
@model_validator(mode='after')
def calculate_total(self):
self.total = self.total_amount
return self
# User Models
class UserBase(BaseModel):
email: EmailStr
full_name: str = Field(..., min_length=1, max_length=100)
phone: Optional[str] = None
class UserCreate(UserBase):
password: str = Field(..., min_length=8)
password_confirm: str
@model_validator(mode='after')
def check_passwords(self):
if self.password != self.password_confirm:
raise ValueError('Passwords do not match')
return self
class User(UserBase, TimestampMixin):
id: int
is_active: bool = True
is_verified: bool = False
class UserInDB(User):
hashed_password: str
# API Response Models
class PaginatedResponse(BaseModel):
items: List[Product]
total: int
page: int
page_size: int
@property
def total_pages(self) -> int:
return (self.total + self.page_size - 1) // self.page_size
# Usage in FastAPI
app = FastAPI()
@app.post("/products/", response_model=Product)
async def create_product(product: ProductCreate):
# product is fully validated
db_product = Product(
id=1,
sku="PROD-001",
**product.model_dump()
)
return db_product
@app.post("/orders/", response_model=Order)
async def create_order(order: OrderCreate):
# Automatically validates items, calculates total, etc.
db_order = Order(
id=1,
user_id=123,
**order.model_dump()
)
return db_orderBest Practices
1. Use Descriptive Field Names
# ❌ Bad
class User(BaseModel):
n: str
e: str
a: int
# ✅ Good
class User(BaseModel):
name: str
email: str
age: int2. Provide Field Descriptions
# ✅ Always describe fields
class Product(BaseModel):
name: str = Field(..., description="Product name")
price: float = Field(..., description="Price in USD", gt=0)
stock: int = Field(..., description="Available quantity", ge=0)3. Use Separate Models for Input/Output
# ✅ Separate concerns
class UserCreate(BaseModel):
username: str
password: str
email: str
class UserUpdate(BaseModel):
email: Optional[str] = None
bio: Optional[str] = None
class UserOut(BaseModel):
id: int
username: str
email: str
# No password!4. Validate Business Logic
# ✅ Use validators for business rules
class Booking(BaseModel):
check_in: datetime
check_out: datetime
guests: int = Field(..., ge=1, le=10)
@model_validator(mode='after')
def check_dates(self):
if self.check_out <= self.check_in:
raise ValueError('Check-out must be after check-in')
if (self.check_out - self.check_in).days > 30:
raise ValueError('Maximum stay is 30 days')
return self5. Use Enums for Fixed Values
# ✅ Type-safe enums
from enum import Enum
class UserRole(str, Enum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
class User(BaseModel):
role: UserRole = UserRole.USERCommon Pitfalls
1. Mutable Default Arguments
# ❌ Bad - mutable default
class User(BaseModel):
tags: list = [] # Shared across instances!
# ✅ Good - use default_factory
class User(BaseModel):
tags: list = Field(default_factory=list)2. Missing Required Fields
# ❌ Bad - unclear if required
class User(BaseModel):
name: str
email: str = None # Actually required but looks optional
# ✅ Good - explicit
class User(BaseModel):
name: str = Field(...) # Required
email: str = Field(...) # Required
bio: str | None = None # Optional3. Over-Validation
# ❌ Bad - too restrictive
class User(BaseModel):
name: str = Field(..., pattern="^[A-Z][a-z]+$") # Too strict!
# ✅ Good - reasonable validation
class User(BaseModel):
name: str = Field(..., min_length=1, max_length=100)Performance Tips
1. Use Pydantic V2
Pydantic v2 is built with Rust and is significantly faster:
pip install "pydantic>=2.0"2. Reuse Models
# ✅ Define once, use everywhere
class User(BaseModel):
name: str
email: str
# Don't recreate the same model multiple times3. Lazy Validation
# Only validate when needed
user_data = {"name": "John", "email": "john@example.com"}
# Don't validate yet
user = User.model_construct(**user_data)
# Validate when ready
user = User(**user_data)Conclusion
Pydantic is the backbone of FastAPI's data handling, providing:
- Automatic Validation - Catch errors before they reach your code
- Type Safety - Full IDE support and type checking
- Clear Errors - Detailed validation error messages
- Flexibility - Custom validators for complex business logic
- Performance - Fast validation with Pydantic v2
- Developer Experience - Write less code, get more features
By mastering Pydantic, you unlock FastAPI's full potential. Use it to build robust, type-safe APIs that validate data automatically, serialize responses correctly, and generate accurate documentation.
Start building better APIs with Pydantic today - your future self will thank you! 🚀
Resources
- Pydantic Documentation: https://docs.pydantic.dev/
- Pydantic V2 Migration: https://docs.pydantic.dev/latest/migration/
- FastAPI Pydantic Guide: https://fastapi.tiangolo.com/tutorial/body/
- Pydantic Settings: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
- GitHub: https://github.com/pydantic/pydantic
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.