Skip to content

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

External Resources