FastAPI Pydantic Validation Errors - Complete Guide
Pydantic validation errors are one of the most common issues in FastAPI development. This guide covers how to handle, customize, and debug validation errors effectively.
Understanding Pydantic Validation
Pydantic automatically validates request data based on your model definitions. When validation fails, FastAPI returns a 422 Unprocessable Entity response with detailed error information.
Basic Validation Example
from pydantic import BaseModel, validator
from fastapi import FastAPI, HTTPException
app = FastAPI()
class UserCreate(BaseModel):
name: str
email: str
age: int
@app.post("/users/")
async def create_user(user: UserCreate):
return {"user": user}
# Valid request:
# POST /users/ {"name": "John", "email": "[email protected]", "age": 25}
# Invalid request returns 422:
# POST /users/ {"name": "", "email": "invalid", "age": "not_a_number"}
Common Validation Errors and Solutions
1. Type Validation Errors
Problem: Wrong data types in request
class Product(BaseModel):
name: str
price: float
quantity: int
is_active: bool
# These cause validation errors:
# {"name": 123, "price": "invalid", "quantity": 1.5, "is_active": "yes"}
Solution: Provide clear error messages and type hints
from typing import Union
from pydantic import BaseModel, Field, validator
class Product(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Product name")
price: float = Field(..., gt=0, description="Product price (must be positive)")
quantity: int = Field(..., ge=0, description="Product quantity (non-negative)")
is_active: bool = Field(True, description="Whether product is active")
class Config:
schema_extra = {
"example": {
"name": "Laptop",
"price": 999.99,
"quantity": 10,
"is_active": True
}
}
2. String Validation Issues
from pydantic import BaseModel, validator, EmailStr
import re
class UserRegistration(BaseModel):
username: str
email: EmailStr
password: str
phone: str
@validator('username')
def username_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError('Username must contain only letters and numbers')
if len(v) < 3:
raise ValueError('Username must be at least 3 characters long')
return v
@validator('password')
def validate_password(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'\d', v):
raise ValueError('Password must contain at least one digit')
return v
@validator('phone')
def validate_phone(cls, v):
phone_pattern = r'^\+?1?\d{9,15}$'
if not re.match(phone_pattern, v):
raise ValueError('Invalid phone number format')
return v
3. Date and DateTime Validation
from datetime import datetime, date
from pydantic import BaseModel, validator
from typing import Optional
class EventCreate(BaseModel):
title: str
start_date: datetime
end_date: datetime
birth_date: Optional[date] = None
@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
@validator('start_date')
def start_date_not_in_past(cls, v):
if v < datetime.now():
raise ValueError('Event cannot be scheduled in the past')
return v
@validator('birth_date')
def validate_birth_date(cls, v):
if v and v > date.today():
raise ValueError('Birth date cannot be in the future')
return v
4. Nested Model Validation
from typing import List, Optional
from pydantic import BaseModel, validator
class Address(BaseModel):
street: str
city: str
country: str
postal_code: str
@validator('postal_code')
def validate_postal_code(cls, v, values):
if 'country' in values:
if values['country'].upper() == 'US' and not re.match(r'^\d{5}(-\d{4})?$', v):
raise ValueError('Invalid US postal code format')
return v
class Contact(BaseModel):
type: str
value: str
@validator('value')
def validate_contact_value(cls, v, values):
if 'type' in values:
if values['type'] == 'email' and '@' not in v:
raise ValueError('Invalid email format')
elif values['type'] == 'phone' and not re.match(r'^\+?[\d\s-()]+$', v):
raise ValueError('Invalid phone format')
return v
class UserProfile(BaseModel):
name: str
address: Address
contacts: List[Contact]
emergency_contact: Optional[Contact] = None
@validator('contacts')
def validate_contacts(cls, v):
if len(v) == 0:
raise ValueError('At least one contact method is required')
contact_types = [contact.type for contact in v]
if len(contact_types) != len(set(contact_types)):
raise ValueError('Duplicate contact types not allowed')
return v
Custom Validation Patterns
1. Conditional Validation
from pydantic import BaseModel, validator, root_validator
from typing import Optional
class PaymentInfo(BaseModel):
payment_method: str
card_number: Optional[str] = None
card_expiry: Optional[str] = None
bank_account: Optional[str] = None
routing_number: Optional[str] = None
@root_validator
def validate_payment_details(cls, values):
payment_method = values.get('payment_method')
if payment_method == 'card':
if not values.get('card_number') or not values.get('card_expiry'):
raise ValueError('Card number and expiry are required for card payments')
elif payment_method == 'bank':
if not values.get('bank_account') or not values.get('routing_number'):
raise ValueError('Bank account and routing number are required for bank payments')
return values
2. Cross-Field Validation
class PasswordReset(BaseModel):
new_password: str
confirm_password: str
current_password: str
@validator('new_password')
def validate_new_password(cls, v, values):
if 'current_password' in values and v == values['current_password']:
raise ValueError('New password must be different from current password')
return v
@root_validator
def passwords_match(cls, values):
new_password = values.get('new_password')
confirm_password = values.get('confirm_password')
if new_password != confirm_password:
raise ValueError('Passwords do not match')
return values
3. Custom Validators with External Data
from pydantic import BaseModel, validator
from typing import Set
# Simulated database of existing usernames
EXISTING_USERNAMES: Set[str] = {"admin", "user", "test"}
class UserCreate(BaseModel):
username: str
email: str
@validator('username')
def username_must_be_unique(cls, v):
if v.lower() in EXISTING_USERNAMES:
raise ValueError('Username already exists')
return v
@validator('email')
def email_must_be_unique(cls, v):
# In real app, check database
existing_emails = {"[email protected]", "[email protected]"}
if v.lower() in existing_emails:
raise ValueError('Email already registered')
return v
Custom Error Handling
1. Custom Exception Handler
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
field_name = " -> ".join(str(loc) for loc in error["loc"][1:]) # Skip 'body'
errors.append({
"field": field_name,
"message": error["msg"],
"invalid_value": error.get("input"),
"error_type": error["type"]
})
return JSONResponse(
status_code=422,
content={
"message": "Validation failed",
"errors": errors,
"total_errors": len(errors)
}
)
@app.exception_handler(ValidationError)
async def pydantic_validation_exception_handler(request: Request, exc: ValidationError):
return JSONResponse(
status_code=422,
content={
"message": "Data validation failed",
"errors": exc.errors()
}
)
2. Detailed Error Messages
from pydantic import BaseModel, validator, Field
from typing import Dict, Any
class DetailedUserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: str = Field(..., regex=r'^[^@]+@[^@]+\.[^@]+$')
age: int = Field(..., ge=13, le=120)
@validator('username')
def validate_username(cls, v):
if not v.isalnum():
raise ValueError(
'Username can only contain letters and numbers. '
'Special characters and spaces are not allowed.'
)
return v
@validator('email')
def validate_email_domain(cls, v):
blocked_domains = ['tempmail.com', 'guerrillamail.com']
domain = v.split('@')[1] if '@' in v else ''
if domain in blocked_domains:
raise ValueError(
f'Email domain "{domain}" is not allowed. '
'Please use a permanent email address.'
)
return v
def create_validation_error_response(errors: list) -> Dict[str, Any]:
"""Create a user-friendly error response"""
formatted_errors = []
for error in errors:
field = error.get('loc', ['unknown'])[-1]
message = error.get('msg', 'Invalid value')
# Custom error messages for common validation types
error_type = error.get('type', '')
if error_type == 'value_error.missing':
message = f"The field '{field}' is required"
elif error_type == 'type_error.integer':
message = f"The field '{field}' must be a number"
elif error_type == 'value_error.any_str.min_length':
message = f"The field '{field}' is too short"
elif error_type == 'value_error.any_str.max_length':
message = f"The field '{field}' is too long"
formatted_errors.append({
'field': field,
'message': message
})
return {
'success': False,
'errors': formatted_errors
}
3. Internationalization of Error Messages
from pydantic import BaseModel, validator
from typing import Dict
ERROR_MESSAGES = {
'en': {
'required_field': 'This field is required',
'invalid_email': 'Please enter a valid email address',
'password_too_short': 'Password must be at least 8 characters long',
'username_taken': 'This username is already taken'
},
'es': {
'required_field': 'Este campo es obligatorio',
'invalid_email': 'Por favor ingrese una dirección de email válida',
'password_too_short': 'La contraseña debe tener al menos 8 caracteres',
'username_taken': 'Este nombre de usuario ya está en uso'
}
}
class LocalizedUserCreate(BaseModel):
username: str
email: str
password: str
language: str = 'en'
@validator('email')
def validate_email(cls, v, values):
lang = values.get('language', 'en')
if '@' not in v:
raise ValueError(ERROR_MESSAGES[lang]['invalid_email'])
return v
@validator('password')
def validate_password(cls, v, values):
lang = values.get('language', 'en')
if len(v) < 8:
raise ValueError(ERROR_MESSAGES[lang]['password_too_short'])
return v
File Upload Validation
from fastapi import FastAPI, File, UploadFile, HTTPException
from pydantic import BaseModel, validator
from typing import Optional
import magic
import os
app = FastAPI()
class FileUploadInfo(BaseModel):
filename: str
content_type: str
size: int
@validator('filename')
def validate_filename(cls, v):
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx'}
file_extension = os.path.splitext(v)[1].lower()
if file_extension not in allowed_extensions:
raise ValueError(f'File type {file_extension} not allowed')
return v
@validator('size')
def validate_file_size(cls, v):
max_size = 10 * 1024 * 1024 # 10MB
if v > max_size:
raise ValueError('File size cannot exceed 10MB')
return v
@app.post("/upload/")
async def upload_file(file: UploadFile = File(...)):
# Read file content for validation
content = await file.read()
# Validate file info
file_info = FileUploadInfo(
filename=file.filename,
content_type=file.content_type,
size=len(content)
)
# Additional MIME type validation
detected_type = magic.from_buffer(content, mime=True)
allowed_types = {'image/jpeg', 'image/png', 'image/gif', 'application/pdf'}
if detected_type not in allowed_types:
raise HTTPException(
status_code=422,
detail=f"Invalid file type: {detected_type}"
)
# Reset file pointer for further processing
await file.seek(0)
return {"message": "File uploaded successfully", "info": file_info}
Query Parameter Validation
from fastapi import FastAPI, Query, HTTPException
from pydantic import BaseModel, validator
from typing import Optional, List
from enum import Enum
app = FastAPI()
class SortOrder(str, Enum):
asc = "asc"
desc = "desc"
class UserListParams(BaseModel):
page: int = Query(1, ge=1, description="Page number")
limit: int = Query(10, ge=1, le=100, description="Items per page")
search: Optional[str] = Query(None, max_length=100)
sort_by: Optional[str] = Query("created_at", regex="^(name|email|created_at)$")
sort_order: SortOrder = Query(SortOrder.desc)
active_only: bool = Query(False)
@validator('search')
def validate_search(cls, v):
if v and len(v.strip()) < 2:
raise ValueError('Search term must be at least 2 characters')
return v.strip() if v else v
@app.get("/users/")
async def list_users(params: UserListParams = Query()):
return {
"users": [],
"pagination": {
"page": params.page,
"limit": params.limit,
"total": 0
},
"filters": params.dict()
}
Testing Validation
1. Unit Testing Validators
import pytest
from pydantic import ValidationError
from your_models import UserCreate
def test_valid_user_creation():
user_data = {
"username": "testuser",
"email": "[email protected]",
"age": 25
}
user = UserCreate(**user_data)
assert user.username == "testuser"
def test_invalid_email():
user_data = {
"username": "testuser",
"email": "invalid-email",
"age": 25
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
errors = exc_info.value.errors()
assert any(error['loc'] == ('email',) for error in errors)
def test_missing_required_field():
user_data = {
"username": "testuser",
# Missing email
"age": 25
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
errors = exc_info.value.errors()
assert any(error['type'] == 'value_error.missing' for error in errors)
def test_custom_validator():
user_data = {
"username": "us", # Too short
"email": "[email protected]",
"age": 25
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
errors = exc_info.value.errors()
username_error = next(error for error in errors if error['loc'] == ('username',))
assert 'at least 3 characters' in username_error['msg']
2. API Testing with FastAPI TestClient
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_user_valid_data():
response = client.post("/users/", json={
"username": "testuser",
"email": "[email protected]",
"age": 25
})
assert response.status_code == 200
def test_create_user_invalid_data():
response = client.post("/users/", json={
"username": "",
"email": "invalid",
"age": "not_a_number"
})
assert response.status_code == 422
error_response = response.json()
assert "errors" in error_response
# Check specific error fields
errors = error_response["errors"]
error_fields = [error["field"] for error in errors]
assert "username" in error_fields
assert "email" in error_fields
assert "age" in error_fields
def test_validation_error_format():
response = client.post("/users/", json={
"username": "ab", # Too short
"email": "[email protected]",
"age": 25
})
assert response.status_code == 422
error_response = response.json()
username_error = next(
error for error in error_response["errors"]
if error["field"] == "username"
)
assert "at least 3 characters" in username_error["message"]
Debugging Validation Issues
1. Enable Detailed Logging
import logging
from pydantic import BaseModel, validator
# Configure logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
class DebugUserCreate(BaseModel):
username: str
email: str
@validator('username', pre=True)
def log_username_validation(cls, v):
logger.debug(f"Validating username: {v} (type: {type(v)})")
return v
@validator('email', pre=True)
def log_email_validation(cls, v):
logger.debug(f"Validating email: {v} (type: {type(v)})")
return v
2. Validation Error Analysis
from pydantic import ValidationError
def analyze_validation_error(error: ValidationError):
"""Analyze and pretty-print validation errors"""
print("Validation Error Analysis:")
print("=" * 50)
for i, error_detail in enumerate(error.errors(), 1):
print(f"\nError {i}:")
print(f" Location: {' -> '.join(map(str, error_detail['loc']))}")
print(f" Type: {error_detail['type']}")
print(f" Message: {error_detail['msg']}")
if 'input' in error_detail:
print(f" Input Value: {error_detail['input']}")
if 'ctx' in error_detail:
print(f" Context: {error_detail['ctx']}")
# Usage
try:
user = UserCreate(username="", email="invalid", age="not_a_number")
except ValidationError as e:
analyze_validation_error(e)
Best Practices
1. Clear Error Messages
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: str
password: str
@validator('username')
def validate_username(cls, v):
if not v.replace('_', '').isalnum():
raise ValueError(
'Username can only contain letters, numbers, and underscores'
)
return v
@validator('password')
def validate_password_strength(cls, v):
issues = []
if len(v) < 8:
issues.append('be at least 8 characters long')
if not re.search(r'[A-Z]', v):
issues.append('contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
issues.append('contain at least one lowercase letter')
if not re.search(r'\d', v):
issues.append('contain at least one number')
if issues:
raise ValueError(f'Password must {", ".join(issues)}')
return v
2. Reusable Validators
from pydantic import validator
import re
def create_email_validator():
@validator('email')
def validate_email(cls, v):
email_pattern = r'^[^@]+@[^@]+\.[^@]+$'
if not re.match(email_pattern, v):
raise ValueError('Invalid email format')
return v.lower()
return validate_email
def create_phone_validator():
@validator('phone')
def validate_phone(cls, v):
phone_pattern = r'^\+?1?\d{9,15}$'
if not re.match(phone_pattern, v):
raise ValueError('Invalid phone number format')
return v
return validate_phone
class UserProfile(BaseModel):
email: str
phone: str
# Apply reusable validators
_validate_email = create_email_validator()
_validate_phone = create_phone_validator()
3. Performance Considerations
from functools import lru_cache
from pydantic import BaseModel, validator
# Cache expensive validations
@lru_cache(maxsize=1000)
def is_valid_domain(domain: str) -> bool:
# Expensive domain validation logic
# In real app, might involve DNS lookup
blocked_domains = {'spam.com', 'fake.org'}
return domain not in blocked_domains
class EmailCreate(BaseModel):
email: str
@validator('email')
def validate_email_domain(cls, v):
domain = v.split('@')[1] if '@' in v else ''
if not is_valid_domain(domain):
raise ValueError('Email domain is not allowed')
return v