FastAPI Field Validation
When developing APIs, validating incoming data is crucial to ensure that your application processes correct and well-structured information. FastAPI, coupled with Pydantic, offers powerful validation capabilities that help maintain data integrity while reducing the amount of validation code you need to write.
Introduction to Field Validation
Field validation in FastAPI is powered by Pydantic models. These models not only define the structure of your data but also enforce constraints on the values that fields can accept. This validation happens automatically when data is received, before it reaches your route functions, giving you clean, validated data to work with.
Let's explore how field validation works in FastAPI and how you can leverage it to build robust APIs.
Basic Field Validation
Using Built-in Validation
Pydantic provides several built-in validators that you can use to enforce constraints on your data:
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr
app = FastAPI()
class User(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
age: int = Field(..., gt=0, lt=120)
is_active: bool = True
@app.post("/users/")
async def create_user(user: User):
return {"user": user, "message": "User created successfully"}
In this example:
username
must be between 3 and 50 characters longemail
must be a valid email address (requiresemail-validator
package)age
must be greater than 0 and less than 120is_active
defaults toTrue
if not provided
Field Options
The Field
function allows you to specify various constraints:
Constraint | Description | Example |
---|---|---|
min_length | Minimum length for strings | Field(..., min_length=5) |
max_length | Maximum length for strings | Field(..., max_length=50) |
gt | Greater than | Field(..., gt=0) |
ge | Greater than or equal | Field(..., ge=18) |
lt | Less than | Field(..., lt=100) |
le | Less than or equal | Field(..., le=99) |
regex | Regular expression pattern | Field(..., regex="^[a-zA-Z]*$") |
Testing Basic Validation
Let's see what happens when we send invalid data to our API:
If we send this JSON:
{
"username": "jo",
"email": "not-an-email",
"age": -5
}
The API would respond with a validation error:
{
"detail": [
{
"loc": ["body", "username"],
"msg": "ensure this value has at least 3 characters",
"type": "value_error.any_str.min_length",
"ctx": {"limit_value": 3}
},
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
},
{
"loc": ["body", "age"],
"msg": "ensure this value is greater than 0",
"type": "value_error.number.not_gt",
"ctx": {"limit_value": 0}
}
]
}
Custom Validators
Sometimes built-in validators aren't enough. For custom validation logic, Pydantic provides the validator
decorator:
from fastapi import FastAPI
from pydantic import BaseModel, validator
app = FastAPI()
class Product(BaseModel):
name: str
price: float
quantity: int
@validator('name')
def name_must_be_capitalized(cls, v):
if not v[0].isupper():
raise ValueError('Product name must start with a capital letter')
return v
@validator('price')
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Price must be positive')
return v
@validator('quantity')
def quantity_must_be_at_least_one(cls, v):
if v < 1:
raise ValueError('Quantity must be at least 1')
return v
@app.post("/products/")
async def create_product(product: Product):
return {"product": product, "message": "Product created successfully"}
Here we've added custom validations to ensure:
- Product name starts with a capital letter
- Price is positive
- Quantity is at least 1
Cross-field Validation
You can also perform validation that depends on multiple fields:
from fastapi import FastAPI
from pydantic import BaseModel, validator
app = FastAPI()
class Discount(BaseModel):
original_price: float
discounted_price: float
@validator('discounted_price')
def discount_must_be_less_than_original(cls, v, values):
if 'original_price' in values and v >= values['original_price']:
raise ValueError('Discounted price must be less than original price')
return v
@app.post("/discounts/")
async def create_discount(discount: Discount):
# Calculate discount percentage
discount_percentage = ((discount.original_price - discount.discounted_price) /
discount.original_price) * 100
return {
"discount": discount,
"discount_percentage": f"{discount_percentage:.2f}%",
"message": "Discount created successfully"
}
In this example, we validate that the discounted price is less than the original price.
Advanced Validation Techniques
Root Validators
When you need to validate multiple fields together or after all fields have been validated:
from fastapi import FastAPI
from pydantic import BaseModel, root_validator
app = FastAPI()
class Order(BaseModel):
items: int
price_per_item: float
total_price: float
@root_validator
def check_total_price(cls, values):
items = values.get('items')
price_per_item = values.get('price_per_item')
total_price = values.get('total_price')
if items and price_per_item and total_price:
expected_total = items * price_per_item
if abs(expected_total - total_price) > 0.01: # Allow small floating point differences
raise ValueError(
f'Total price {total_price} does not match items * price_per_item = {expected_total}'
)
return values
@app.post("/orders/")
async def create_order(order: Order):
return {"order": order, "message": "Order created successfully"}
Field Aliases
Sometimes your API needs to use a different field name than what's in the JSON:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class User(BaseModel):
user_id: int = Field(..., alias="userId")
full_name: str = Field(..., alias="fullName")
email: str
class Config:
allow_population_by_field_name = True
@app.post("/users/")
async def create_user(user: User):
return {"user": user, "message": "User created successfully"}
This allows clients to send data with camelCase field names (userId
, fullName
) while your Python code uses snake_case (user_id
, full_name
).
Constrained Types
For frequently used validation patterns, Pydantic provides constrained types:
from fastapi import FastAPI
from pydantic import BaseModel, constr, conint, confloat
app = FastAPI()
class Item(BaseModel):
name: constr(min_length=3, max_length=50)
description: constr(max_length=1000)
price: confloat(gt=0)
quantity: conint(ge=0)
tags: list[constr(min_length=2, regex=r'^[a-zA-Z0-9]+$')]
@app.post("/items/")
async def create_item(item: Item):
return {"item": item, "message": "Item created successfully"}
Custom Error Messages
You can customize error messages for better user experience:
from fastapi import FastAPI
from pydantic import BaseModel, Field, validator
app = FastAPI()
class SignupData(BaseModel):
username: str = Field(
...,
min_length=5,
max_length=20,
description="Username must be between 5 and 20 characters"
)
password: str
@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not any(char.isdigit() for char in v):
raise ValueError('Password must contain at least one digit')
if not any(char.isupper() for char in v):
raise ValueError('Password must contain at least one uppercase letter')
return v
@app.post("/signup/")
async def signup(data: SignupData):
# In a real app, you would hash the password before storing it
return {"username": data.username, "message": "Signup successful"}
Real-World Application: User Registration Form
Let's build a more comprehensive example that resembles a real-world user registration API:
from typing import Optional
from datetime import date, datetime
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field, validator, root_validator
import re
app = FastAPI()
class UserRegistration(BaseModel):
username: str = Field(..., min_length=4, max_length=20)
email: EmailStr
password: str
confirm_password: str
birth_date: date
bio: Optional[str] = Field(default=None, max_length=500)
terms_accepted: bool = Field(...)
@validator('username')
def username_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Username must be alphanumeric with underscores only')
return v
@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain at least one digit')
if not re.search(r'[^a-zA-Z0-9]', v):
raise ValueError('Password must contain at least one special character')
return v
@validator('birth_date')
def validate_age(cls, v):
today = date.today()
age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
if age < 13:
raise ValueError('Users must be at least 13 years old')
if age > 120:
raise ValueError('Invalid birth date')
return v
@root_validator
def passwords_match(cls, values):
pw1, pw2 = values.get('password'), values.get('confirm_password')
if pw1 and pw2 and pw1 != pw2:
raise ValueError('Passwords do not match')
if not values.get('terms_accepted'):
raise ValueError('You must accept the terms and conditions')
return values
class Config:
schema_extra = {
"example": {
"username": "johndoe42",
"email": "[email protected]",
"password": "SecureP@ss123",
"confirm_password": "SecureP@ss123",
"birth_date": "1990-01-01",
"bio": "Hello, I'm John!",
"terms_accepted": True
}
}
@app.post("/register/", response_model=dict)
async def register_user(user: UserRegistration):
# In a real application, you would:
# 1. Check if username or email already exists in database
# 2. Hash the password
# 3. Store the user in the database
# 4. Generate a confirmation email or token
# For demonstration purposes, we'll just return success
return {
"message": "Registration successful",
"username": user.username,
"email": user.email,
"registered_at": datetime.now().isoformat()
}
This example demonstrates a comprehensive registration form that:
- Validates username format
- Ensures strong passwords
- Checks that passwords match
- Verifies the user is at least 13 years old
- Requires terms acceptance
- Provides an example in the API documentation
Summary
Field validation in FastAPI using Pydantic provides a powerful way to ensure data integrity with minimal code. We've explored:
- Basic field validation using built-in constraints
- Custom validators for more complex rules
- Cross-field validation for interdependent fields
- Advanced techniques like root validators and field aliases
- Real-world examples showing practical applications
By leveraging these validation capabilities, you can build robust APIs that gracefully handle invalid inputs and provide clear error messages to users, all while keeping your code clean and maintainable.
Additional Resources
Exercises
- Create a model for a blog post with appropriate validations (title length, content requirements, etc.)
- Implement a credit card validation model that checks for valid format, expiration date, etc.
- Build an address validation model that ensures postal codes match country formats
- Create a model for a product inventory system with cross-field validations (e.g., making sure low stock alerts are set appropriately)
- Implement custom error messages for all validations in one of the above exercises
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)