Skip to main content

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:

python
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:

  1. username_must_be_alphanumeric: Ensures the username only contains letters and numbers
  2. password_minimum_length: Ensures the password is at least 8 characters long

When you use this model in your FastAPI app, validation happens automatically:

python
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:

json
{
"username": "user@name",
"password": "123"
}

FastAPI will respond with a validation error:

json
{
"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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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

  1. Keep validators simple: Each validator should focus on one task
  2. Use descriptive error messages: They help API consumers understand what went wrong
  3. Validate early: Catch errors as early as possible in your data flow
  4. Don't repeat validation: If the same validation happens in multiple models, consider using a mixin or a function
  5. 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

  1. Create a BlogPost model with validators that ensure the title is between 5-100 characters and the content is at least 50 characters
  2. Implement validators for an Address model that validate zip codes and phone numbers according to a specific country's format
  3. Build a TransferRequest model for a banking app that validates the transfer amount doesn't exceed the account balance
  4. 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! :)