FastAPI Validators
When building APIs with FastAPI, data validation is a crucial part of ensuring your application behaves correctly. While Pydantic's type annotations provide basic validation, custom validators allow you to implement more complex validation rules for your data models.
What are Validators?
Validators in FastAPI are functions that check if the data provided in a request meets specific criteria or business rules. These validators are implemented using Pydantic's validation system and allow you to:
- Enforce complex validation rules beyond simple type checking
- Transform input data into a desired format
- Ensure data consistency across your application
- Implement business logic constraints
Basic Validator Usage
Let's start with a simple example of how to define a custom validator with Pydantic:
from pydantic import BaseModel, validator
class User(BaseModel):
username: str
password: str
@validator('username')
def username_must_be_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError('Username must contain only alphanumeric characters')
return v
@validator('password')
def password_minimum_length(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
return v
In this example, we've defined two validators:
username_must_be_alphanumeric
: Ensures the username only contains letters and numberspassword_minimum_length
: Ensures the password is at least 8 characters long
When you use this model in your FastAPI app, validation happens automatically:
from fastapi import FastAPI
app = FastAPI()
@app.post("/users/")
async def create_user(user: User):
return {"username": user.username, "message": "User created successfully"}
If a client sends invalid data:
{
"username": "user@name",
"password": "123"
}
FastAPI will respond with a validation error:
{
"detail": [
{
"loc": ["body", "username"],
"msg": "Username must contain only alphanumeric characters",
"type": "value_error"
},
{
"loc": ["body", "password"],
"msg": "Password must be at least 8 characters long",
"type": "value_error"
}
]
}
Advanced Validator Features
Pre-Validation and Root Validation
Pydantic allows you to specify whether validation should happen before or after default values are applied using the pre
parameter:
from pydantic import BaseModel, validator
class Product(BaseModel):
name: str
price: float
discount: float = 0.0
final_price: float = 0.0
@validator('price')
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Price must be positive')
return v
@validator('final_price', pre=True)
def calculate_final_price(cls, v, values):
# This runs before the default value is applied
price = values.get('price', 0)
discount = values.get('discount', 0)
return price * (1 - discount)
For model-wide validation that depends on multiple fields, you can use the special root_validator
:
from pydantic import BaseModel, root_validator
class Order(BaseModel):
items: int
price_per_item: float
total_price: float
@root_validator
def check_total_price(cls, values):
items = values.get('items', 0)
price_per_item = values.get('price_per_item', 0)
total_price = values.get('total_price', 0)
if items * price_per_item != total_price:
raise ValueError('Total price does not match items × price per item')
return values
Using Field Values in Validators
Notice in the above examples, we access other field values using the values
parameter. This is a dictionary containing all previously validated fields:
from datetime import date
from pydantic import BaseModel, validator
class Event(BaseModel):
start_date: date
end_date: date
@validator('end_date')
def end_date_after_start_date(cls, v, values):
if 'start_date' in values and v < values['start_date']:
raise ValueError('End date must be after start date')
return v
Validators with Custom Error Messages
You can customize error messages to be more user-friendly:
from pydantic import BaseModel, validator, EmailStr
class SignupForm(BaseModel):
email: EmailStr
password: str
password_confirm: str
@validator('password')
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 an uppercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain a number')
return v
@validator('password_confirm')
def passwords_match(cls, v, values):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v
Real-World Examples
Example 1: User Registration System
Let's build a more comprehensive user registration system with several validation rules:
from pydantic import BaseModel, validator, EmailStr, Field
from datetime import date, datetime
import re
class UserRegistration(BaseModel):
username: str
email: EmailStr
password: str
birth_date: date
registration_date: datetime = Field(default_factory=datetime.now)
@validator('username')
def username_valid(cls, v):
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', v):
raise ValueError('Username must be 3-20 characters long and contain only letters, numbers, and underscores')
return v
@validator('password')
def password_complexity(cls, v):
# Check if password is at least 8 chars, has uppercase, lowercase, digit and special char
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 an uppercase letter')
if not any(c.islower() for c in v):
raise ValueError('Password must contain a lowercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain a digit')
if not any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?/~`' for c in v):
raise ValueError('Password must contain a special character')
return v
@validator('birth_date')
def check_age(cls, v):
today = date.today()
age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
if age < 13:
raise ValueError('User must be at least 13 years old')
return v
This model implements several validation rules:
- Username must follow a specific pattern
- Password must meet multiple complexity requirements
- Users must be at least 13 years old
Example 2: E-commerce Product Management
Let's create a product management system with validators:
from pydantic import BaseModel, validator, root_validator
from typing import List, Optional
from enum import Enum
from datetime import datetime
class ProductCategory(str, Enum):
ELECTRONICS = "electronics"
CLOTHING = "clothing"
BOOKS = "books"
HOME = "home"
OTHER = "other"
class Product(BaseModel):
id: Optional[int] = None
name: str
description: str
price: float
sale_price: Optional[float] = None
category: ProductCategory
tags: List[str] = []
in_stock: bool = True
stock_count: Optional[int] = None
created_at: datetime = Field(default_factory=datetime.now)
@validator('name')
def name_not_empty(cls, v):
if not v.strip():
raise ValueError('Name cannot be empty')
return v.strip()
@validator('description')
def description_minimum_length(cls, v):
if len(v) < 10:
raise ValueError('Description must be at least 10 characters')
return v
@validator('price', 'sale_price')
def price_must_be_positive(cls, v):
if v is not None and v < 0:
raise ValueError('Price must be positive')
return v
@root_validator
def sale_price_less_than_price(cls, values):
price = values.get('price')
sale_price = values.get('sale_price')
if sale_price is not None:
if price is None:
raise ValueError('Regular price must be set if sale price is provided')
if sale_price >= price:
raise ValueError('Sale price must be less than regular price')
in_stock = values.get('in_stock')
stock_count = values.get('stock_count')
if in_stock and (stock_count is None or stock_count <= 0):
raise ValueError('Stock count must be positive for in-stock products')
elif not in_stock and stock_count is not None and stock_count > 0:
raise ValueError('Out-of-stock products should have zero stock count')
return values
@validator('tags')
def normalize_tags(cls, v):
# Convert tags to lowercase and remove duplicates
return list(set(tag.lower() for tag in v))
This product model includes:
- Basic field validation (non-empty name, minimum description length)
- Business logic (sale price must be less than regular price)
- Consistency checks (in-stock products must have positive stock count)
- Data normalization (converting tags to lowercase and removing duplicates)
FastAPI Integration
Let's see how these validators work within a FastAPI application:
from fastapi import FastAPI, HTTPException, status
from typing import List
app = FastAPI()
# In-memory database for products
products_db = []
product_id_counter = 1
@app.post("/products/", response_model=Product, status_code=status.HTTP_201_CREATED)
async def create_product(product: Product):
global product_id_counter
# Assign product ID
product.id = product_id_counter
product_id_counter += 1
# Validation happens automatically due to the Pydantic model
products_db.append(product)
return product
@app.get("/products/", response_model=List[Product])
async def list_products():
return products_db
@app.put("/products/{product_id}", response_model=Product)
async def update_product(product_id: int, updated_product: Product):
for i, product in enumerate(products_db):
if product.id == product_id:
# Keep the original ID
updated_product.id = product_id
products_db[i] = updated_product
return updated_product
raise HTTPException(status_code=404, detail="Product not found")
The validation happens automatically when the request body is parsed into the Pydantic model. If any validator raises a ValueError
, FastAPI converts it into a validation error response.
Custom Validators vs. Dependencies
While Pydantic validators are great for model-level validation, FastAPI also offers dependencies for request-level validation. Here's a quick comparison:
Use Pydantic validators when:
- Validating individual fields or relationships between fields
- The validation logic is tightly coupled to the data model
- You want validation to happen automatically for any usage of the model
Use FastAPI dependencies when:
- Validation requires database queries or external API calls
- Implementing authentication or authorization logic
- The same validation needs to be reused across multiple endpoints
- Validation depends on request headers, cookies, or other non-body parts
Practical Tips for Validators
- Keep validators simple: Each validator should focus on one task
- Use descriptive error messages: They help API consumers understand what went wrong
- Validate early: Catch errors as early as possible in your data flow
- Don't repeat validation: If the same validation happens in multiple models, consider using a mixin or a function
- Test your validators: Write unit tests to ensure they work correctly
Summary
Validators in FastAPI are a powerful tool for ensuring data integrity in your application. They allow you:
- Implement complex business rules and data validation
- Transform and normalize input data
- Ensure consistency across your application
- Provide clear and helpful error messages
By combining FastAPI's automatic validation with custom Pydantic validators, you can build robust APIs that gracefully handle invalid inputs and guide users toward correct usage.
Additional Resources
Exercises
- Create a
BlogPost
model with validators that ensure the title is between 5-100 characters and the content is at least 50 characters - Implement validators for an
Address
model that validate zip codes and phone numbers according to a specific country's format - Build a
TransferRequest
model for a banking app that validates the transfer amount doesn't exceed the account balance - Create a
Survey
model with validators that ensure at least 3 questions are provided and each question has at least 2 possible answers
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)