Skip to main content

FastAPI Data Validation

In this tutorial, you'll learn how FastAPI handles data validation and how you can leverage this feature to build robust APIs. Data validation is one of FastAPI's most powerful features, allowing you to automatically validate incoming data and provide clear error messages when data doesn't meet your requirements.

Introduction to Data Validation in FastAPI

When building APIs, ensuring that incoming data conforms to your expected format and constraints is critical. Invalid data can lead to errors, security vulnerabilities, or corrupted database entries.

FastAPI provides a powerful data validation system built on top of Pydantic, a data validation library that uses Python type annotations. This integration allows you to:

  • Validate incoming request data automatically
  • Convert data to the appropriate Python types
  • Generate clear error messages for invalid data
  • Document your API requirements automatically

Let's dive into how this works!

Basic Data Validation with Pydantic Models

At the core of FastAPI's validation is the Pydantic model. These models define the structure and types of your data.

Creating Your First Pydantic Model

python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
name: str
price: float
is_offer: bool = False # Optional field with default value

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

In this example:

  1. We created a Pydantic model called Item with three fields: name, price, and is_offer.
  2. The name must be a string, price must be a float, and is_offer is an optional boolean with a default value of False.
  3. When a POST request is made to /items/, FastAPI will:
    • Read the request body as JSON
    • Convert the data to the Item model
    • Validate the data types and constraints
    • Provide the validated item instance to your function

See it in Action

Let's test our API with a valid request:

POST /items/
Content-Type: application/json

{
"name": "Smartphone",
"price": 699.99
}

Response:

json
{
"name": "Smartphone",
"price": 699.99,
"is_offer": false
}

Now let's try an invalid request:

POST /items/
Content-Type: application/json

{
"name": "Smartphone",
"price": "expensive"
}

Response:

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

FastAPI automatically detected that "expensive" is not a valid float and returned a helpful error message.

Advanced Validation with Field Constraints

Pydantic offers more advanced validation through field constraints. Here's how to add constraints to your models:

python
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List

app = FastAPI()

class User(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
full_name: Optional[str] = None
age: int = Field(..., gt=0, lt=120)
tags: List[str] = []

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

In this example:

  • username must be between 3 and 50 characters
  • email must be a valid email address (requires installing pydantic[email])
  • full_name is optional
  • age must be greater than 0 and less than 120
  • tags is a list of strings with a default empty list

Using Field for Validation

The Field function provides several validation parameters:

  • default: Default value if not provided
  • ...: Indicates the field is required
  • gt, ge: Greater than, Greater than or equal to
  • lt, le: Less than, Less than or equal to
  • min_length, max_length: String length constraints
  • regex: Regular expression pattern

Path and Query Parameter Validation

FastAPI can validate not just request bodies but also path and query parameters:

python
from fastapi import FastAPI, Path, Query
from typing import Optional

app = FastAPI()

@app.get("/items/{item_id}")
async def read_item(
item_id: int = Path(..., gt=0, title="The ID of the item"),
q: Optional[str] = Query(None, min_length=3, max_length=50)
):
return {"item_id": item_id, "q": q}

In this example:

  • item_id from the path must be a positive integer
  • q is an optional query parameter that, if provided, must be between 3 and 50 characters

Request Body + Path + Query Parameters

You can combine different parameter types in your endpoints:

python
from fastapi import FastAPI, Path, Query
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None

@app.put("/items/{item_id}")
async def update_item(
item_id: int = Path(..., gt=0),
item: Item = None,
q: Optional[str] = Query(None, max_length=50)
):
result = {"item_id": item_id}
if item:
result.update(item.dict())
if q:
result["q"] = q
return result

This endpoint takes:

  1. A path parameter item_id
  2. A request body item
  3. An optional query parameter q

Custom Validators

Sometimes you need more complex validation logic. Pydantic provides field and model validators:

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

app = FastAPI()

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

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

@validator('price')
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Price must be positive')
return v

@root_validator
def check_total_value(cls, values):
if 'price' in values and 'quantity' in values:
if values['price'] * values['quantity'] > 10000:
raise ValueError('Total value cannot exceed $10,000')
return values

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

In this example:

  • name_must_be_capitalized ensures the name starts with a capital letter
  • price_must_be_positive ensures the price is positive
  • check_total_value ensures the total value (price × quantity) doesn't exceed $10,000

Real-World Example: User Registration API

Let's build a more complex example: a user registration API with thorough validation:

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

app = FastAPI()

class UserRegistration(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: EmailStr
password: str = Field(..., min_length=8)
confirm_password: str
birth_date: Optional[date] = None
terms_accepted: bool = Field(..., description="User must accept terms and conditions")

@validator('username')
def username_alphanumeric(cls, v):
if not re.match('^[a-zA-Z0-9_-]+$', v):
raise ValueError('Username must be alphanumeric with optional underscores and hyphens')
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 check_age(cls, v):
if v is not None:
today = date.today()
age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
if age < 18:
raise ValueError('User must be at least 18 years old')
return v

@validator('terms_accepted')
def terms_must_be_accepted(cls, v):
if not v:
raise ValueError('Terms and conditions must be accepted')
return v

@app.post("/register/", status_code=status.HTTP_201_CREATED)
async def register_user(user: UserRegistration):
# In a real app, you would hash the password and store the user in a database
# This is a simplified example
return {
"message": "User registered successfully",
"username": user.username,
"email": user.email
}

This example includes:

  • Username validation (length and allowed characters)
  • Email validation
  • Password matching validation
  • Age verification (must be 18+)
  • Terms and conditions acceptance requirement

Handling Validation Errors

When validation fails, FastAPI automatically returns a 422 Unprocessable Entity response with details about the validation errors. You can customize how these errors are handled:

python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

app = FastAPI()

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

This custom exception handler returns a 400 Bad Request status code with a more structured error response.

Summary

Data validation is a critical part of building robust APIs, and FastAPI makes it simple and efficient with its Pydantic integration. Key takeaways:

  • Use Pydantic models to define the structure and constraints of your data
  • FastAPI automatically validates incoming request bodies, path parameters, and query parameters
  • Add field constraints with Field, Path, and Query functions
  • Create custom validators for complex validation logic
  • Handle validation errors gracefully

By properly validating incoming data, you can build more reliable APIs that are easier to use and less prone to bugs or security issues.

Additional Resources

Exercises

  1. Create a Pydantic model for a product with name, description, price, and categories (list of strings). Add appropriate validation rules.

  2. Build an API endpoint that accepts user information (name, email, age) and validates that the user is between 18 and 65 years old.

  3. Extend the user registration example to include address validation with country, postal code, and phone number validation.

  4. Create a custom validation error handler that formats error messages in a user-friendly way.

  5. Build a form submission API that validates that all required fields are filled and that dates are in the correct format.



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