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
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:
- We created a Pydantic model called
Item
with three fields:name
,price
, andis_offer
. - The
name
must be a string,price
must be a float, andis_offer
is an optional boolean with a default value ofFalse
. - 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:
{
"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:
{
"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:
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 charactersemail
must be a valid email address (requires installingpydantic[email]
)full_name
is optionalage
must be greater than 0 and less than 120tags
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 requiredgt
,ge
: Greater than, Greater than or equal tolt
,le
: Less than, Less than or equal tomin_length
,max_length
: String length constraintsregex
: Regular expression pattern
Path and Query Parameter Validation
FastAPI can validate not just request bodies but also path and query parameters:
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 integerq
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:
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:
- A path parameter
item_id
- A request body
item
- An optional query parameter
q
Custom Validators
Sometimes you need more complex validation logic. Pydantic provides field and model validators:
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 letterprice_must_be_positive
ensures the price is positivecheck_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:
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:
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
, andQuery
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
-
Create a Pydantic model for a product with name, description, price, and categories (list of strings). Add appropriate validation rules.
-
Build an API endpoint that accepts user information (name, email, age) and validates that the user is between 18 and 65 years old.
-
Extend the user registration example to include address validation with country, postal code, and phone number validation.
-
Create a custom validation error handler that formats error messages in a user-friendly way.
-
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! :)