Back to blog

Mastering Pydantic in FastAPI: Data Validation Made Easy

pythonfastapipydanticvalidationapi
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 representation

What 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 limits
  • pattern - Regex pattern
  • strip_whitespace - Remove leading/trailing whitespace

For Numbers:

  • gt / ge - Greater than / greater or equal
  • lt / le - Less than / less or equal
  • multiple_of - Must be multiple of value

For Collections:

  • min_length / max_length - Collection size
  • unique_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 = None

Note: Some validators require extra dependencies:

pip install "pydantic[email]"  # For EmailStr

Custom 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 v

Model 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 self

Before 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 self

Nested 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 automatically

Optional 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+ syntax

Union 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: str

Extra 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)  # ValidationError

Immutable 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 frozen

Serialization 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 excluded

Custom 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 inheritance

Settings 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_order

Best 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: int

2. 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 self

5. 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.USER

Common 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  # Optional

3. 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 times

3. 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

📬 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.