Skip to main content

FastAPI Model Validation

One of FastAPI's most powerful features is its built-in data validation system, which leverages Pydantic models to ensure that incoming request data meets your specified requirements. In this tutorial, you'll learn how to validate input data, enforce constraints, and handle validation errors in your FastAPI applications.

Introduction to Data Validation

When building APIs, ensuring the correctness and integrity of input data is crucial. Without proper validation:

  • Users might submit incomplete or incorrect data
  • Your application could process invalid values, causing errors further down the line
  • Security vulnerabilities might emerge due to unexpected inputs

FastAPI uses Pydantic models to automatically validate, parse, and document request data. This validation happens before your endpoint function is called, giving you confidence that the data you're working with meets your requirements.

Basic Validation with Pydantic Models

Let's start with a simple example of how FastAPI uses Pydantic models for validation:

python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
name: str
price: float
is_available: bool

@app.post("/items/")
async def create_item(item: Item):
return {"message": "Item created successfully", "item": item}

When a POST request is sent to the /items/ endpoint, FastAPI will:

  1. Read the request body as JSON
  2. Validate the data against the Item model
  3. Convert the data to a Python object if validation passes
  4. Reject the request with clear error messages if validation fails

Example request:

json
{
"name": "Laptop",
"price": 999.99,
"is_available": true
}

Example response (status code 200):

json
{
"message": "Item created successfully",
"item": {
"name": "Laptop",
"price": 999.99,
"is_available": true
}
}

If we send an invalid request like:

json
{
"name": "Laptop",
"price": "expensive",
"is_available": true
}

FastAPI automatically generates a validation error (status code 422):

json
{
"detail": [
{
"loc": ["body", "price"],
"msg": "value is not a valid float",
"type": "type_error.float"
}
]
}

Field Constraints and Validators

Pydantic offers various field constraints to add more specific validation rules:

python
from fastapi import FastAPI
from pydantic import BaseModel, Field, validator

app = FastAPI()

class Product(BaseModel):
name: str = Field(..., min_length=3, max_length=50)
price: float = Field(..., gt=0)
description: str = Field(default="No description provided", max_length=1000)
category: str = Field(..., pattern="^[a-zA-Z]+$") # Only letters allowed
tags: list[str] = Field(default_factory=list)

@validator('name')
def name_must_be_capitalized(cls, v):
if not v[0].isupper():
raise ValueError('Name must start with uppercase letter')
return v

@app.post("/products/")
async def create_product(product: Product):
return {"message": "Product created", "product": product}

Let's break down the constraints:

  • min_length, max_length: Control the length of strings
  • gt (greater than): Ensures the price is positive
  • default: Provides a default value if the field is not provided
  • pattern: Uses a regex pattern to validate the string format
  • validator: A custom function that adds complex validation logic

Example valid request:

json
{
"name": "Headphones",
"price": 89.99,
"description": "Premium wireless headphones",
"category": "Audio",
"tags": ["electronics", "wireless", "premium"]
}

Optional and Required Fields

In Pydantic models, fields are required by default. There are several ways to make fields optional:

python
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
username: str # Required
full_name: Optional[str] = None # Optional
bio: str = "" # Optional with default empty string
age: int | None = None # Python 3.10+ union syntax, optional

@app.post("/users/")
async def create_user(user: User):
return {"user": user}

This model allows for various combinations of fields to be submitted:

json
{
"username": "johndoe"
}
json
{
"username": "johndoe",
"full_name": "John Doe",
"bio": "Software developer",
"age": 30
}

Complex Validation

For more complex validation scenarios, Pydantic provides powerful tools:

python
from datetime import datetime, date
from typing import List, Optional
from fastapi import FastAPI
from pydantic import BaseModel, validator, Field, root_validator

app = FastAPI()

class BookingRequest(BaseModel):
room_id: int
guest_name: str
check_in: date
check_out: date
guests: int = Field(..., ge=1, le=4) # Between 1 and 4 guests
special_requests: Optional[List[str]] = None

@validator('check_in')
def check_in_must_be_future(cls, v):
if v < date.today():
raise ValueError('Check-in date must be in the future')
return v

@root_validator
def check_dates(cls, values):
check_in, check_out = values.get('check_in'), values.get('check_out')
if check_in and check_out and check_in >= check_out:
raise ValueError('Check-out must be after check-in')
return values

@app.post("/bookings/")
async def create_booking(booking: BookingRequest):
# Process booking
return {"booking_confirmed": True, "booking": booking}

In this example:

  • @validator decorators validate individual fields, like ensuring the check-in date is in the future
  • @root_validator performs validation that depends on multiple fields, like checking that check-out comes after check-in
  • The ge and le constraints ensure the number of guests is between 1 and 4

Handling Validation Errors

By default, FastAPI returns a 422 Unprocessable Entity response with detailed validation errors. You can customize error handling:

python
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ValidationError

app = FastAPI()

class Item(BaseModel):
name: str
price: float

@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content=jsonable_encoder({
"message": "Validation error",
"details": exc.errors()
})
)

@app.post("/items/")
async def create_item(item: Item):
return {"item": item}

This creates a custom exception handler that transforms validation errors into a more user-friendly format with a 400 status code instead of 422.

Real-World Example: User Registration API

Let's build a more comprehensive example - a user registration API with validation:

python
from datetime import date
from typing import Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field, validator
import re

app = FastAPI()

class UserRegistration(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
password: str = Field(..., min_length=8)
confirm_password: str
birth_date: Optional[date] = None
first_name: Optional[str] = None
last_name: Optional[str] = None

@validator('username')
def username_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Username must contain only letters, numbers, and underscores')
return v

@validator('password')
def password_strength(cls, v):
if not any(c.isupper() for c in v):
raise ValueError('Password must contain at least one uppercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain at least one digit')
return v

@validator('confirm_password')
def passwords_match(cls, v, values):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v

@validator('birth_date')
def validate_age(cls, v):
if v and v > date.today():
raise ValueError('Birth date cannot be in the future')
return v

@app.post("/register/")
async def register_user(user: UserRegistration):
# In a real app, you would hash the password and save the user to a database

# Return user info (excluding the password)
return {
"message": "User registered successfully",
"user": {
"username": user.username,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"birth_date": user.birth_date
}
}

This example demonstrates:

  • Email validation with EmailStr (requires pip install pydantic[email])
  • Password strength validation and confirmation
  • Pattern validation for username with regex
  • Date validation to ensure birth dates are realistic

Summary

FastAPI's integration with Pydantic provides a powerful, declarative way to validate incoming data:

  1. Basic validation ensures data types are correct
  2. Field constraints add specific rules like min/max values
  3. Custom validators allow for complex, multi-field validation logic
  4. Error handling provides clear feedback to API consumers

By leveraging this validation system, you can:

  • Build more robust APIs
  • Reduce error-handling code in your application
  • Automatically generate documentation that includes validation rules
  • Focus on your business logic rather than input validation

Additional Resources and Exercises

Resources

Exercises

  1. Basic validation: Create a model for a blog post with title, content, author, and publication date. Add appropriate validation rules.

  2. Complex validation: Create a model for a shipping order with source and destination addresses, items list, and shipping preferences. Validate that the addresses are different and that the items list is not empty.

  3. Custom error messages: Modify the user registration example to provide more user-friendly error messages for each validation rule.

  4. Advanced validators: Create a model for a credit card payment with validation for:

    • Card number format and checksum (Luhn algorithm)
    • Expiration date in the future
    • CVV format based on card type

Master these validation techniques, and you'll be well-equipped to build APIs that are both user-friendly and robust against bad data.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)