8.5. Data Validation
Table of Contents
Why Data Validation Matters in FastAPI
Data validation means checking that incoming data is correct, safe, and in the format your application expects. In FastAPI this is mostly handled for you by Pydantic models.
If you do not validate data:
- Invalid data can crash your code.
- Wrong types can silently corrupt your database.
- Attackers can try to inject dangerous content.
FastAPI is designed so that validation happens at the boundaries of your application, when data enters or leaves your API. Once data passes validation, your internal code can trust it much more.
Important rule: In FastAPI, you should always define request bodies and complex query/path parameters with Pydantic models to get automatic validation and clear error messages.
Validation with Pydantic Models
FastAPI uses Pydantic (v1 at time of writing) to define data schemas and validate them.
A simple model:
from pydantic import BaseModel
class UserCreate(BaseModel):
username: str
email: str
age: intUse it in an endpoint:
from fastapi import FastAPI
app = FastAPI()
@app.post("/users")
def create_user(user: UserCreate):
# Here `user` is already validated
return {"message": "User created", "user": user}What happens automatically:
- FastAPI reads the request body as JSON.
- Pydantic checks that:
usernameis present and is a string.emailis present and is a string.ageis present and is an integer.- If validation fails, FastAPI returns
422 Unprocessable Entitywith details.
Example invalid request:
{
"username": "alice",
"email": 123,
"age": "twenty"
}Response:
{
"detail": [
{
"loc": ["body", "email"],
"msg": "value is not a valid string",
"type": "type_error.str"
},
{
"loc": ["body", "age"],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
}You did not write any validation code yourself. FastAPI and Pydantic did it.
Required vs Optional Fields
Pydantic decides if a field is required or optional from the type annotation.
Required fields
If a field has a plain type like str or int, it is required.
class UserCreate(BaseModel):
username: str # required
email: str # required
age: int # required
If the client omits age:
{"username": "alice", "email": "alice@example.com"}Response:
{
"detail": [
{
"loc": ["body", "age"],
"msg": "field required",
"type": "value_error.missing"
}
]
}Optional fields
Use Optional[type] or type | None to mark a field as optional.
from typing import Optional
class UserCreate(BaseModel):
username: str # required
email: str # required
age: Optional[int] = None # optional
Now age can be missing or null:
{
"username": "alice",
"email": "alice@example.com"
}
This is valid, and inside the function user.age will be None.
You can also use the | None syntax (Python 3.10+):
class UserCreate(BaseModel):
username: str
email: str
age: int | None = NoneRule: A field is optional only if:
- The type includes
None(for exampleOptional[int]), and - It has a default value (often
None).
If you forget the default, Pydantic still treats it as required.
Example of a subtle bug:
class UserCreate(BaseModel):
age: Optional[int] # This is still required by default!
Request without age will fail, because there is no default value. To make it truly optional, write:
age: Optional[int] = NoneBasic Type Validation
Pydantic validates the value and often tries to coerce types where possible.
| Type annotation | Example valid input | Notes |
|---|---|---|
int | "5", 5 | Tries to cast strings like "5" to int |
float | "3.14", 3.14, 5 | Casts where possible |
str | 123, "hello" | Converts other types to string |
bool | true, false, "true" | Many truthy / falsy values are accepted |
List[int] | [1, 2, 3] | Validates each element |
Dict[str, int] | {"a": 1, "b": 2} | Validates keys and values |
Example:
from typing import List, Dict
from pydantic import BaseModel
class Order(BaseModel):
id: int
items: List[str]
quantities: Dict[str, int]Valid JSON:
{
"id": "1",
"items": ["apple", "banana"],
"quantities": {"apple": 2, "banana": 3}
}
id string "1" will be converted to int 1.
If quantities has a non integer value:
{"quantities": {"apple": "two"}}you get a validation error.
Using Pydantic Field Constraints
Pydantic lets you add constraints to fields, for example minimum length, maximum value, and more.
You can use:
- "Constrained types" like
constr,conint,confloat, etc. - Or the
Fieldfunction with extra arguments.
Using `Field` with constraints
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(..., min_length=3, max_length=50)
price: float = Field(..., gt=0) # greater than 0
stock: int = Field(0, ge=0) # greater or equal to 0, default 0Explanation:
Field(..., ...)means the field is required.min_length=3means strings shorter than 3 characters are invalid.gt=0means strictly greater than 0.ge=0means greater or equal to 0.
Example invalid request:
{
"name": "TV",
"price": -10,
"stock": -5
}Validation errors will show each violated constraint.
Common Field constraints for numbers:
| Argument | Meaning |
|---|---|
gt | greater than |
ge | greater than or equal |
lt | less than |
le | less than or equal |
Common Field constraints for strings / lists:
| Argument | Meaning |
|---|---|
min_length | minimum number of characters |
max_length | maximum number of characters |
regex | must match regular expression |
Example with regex:
class Username(BaseModel):
username: str = Field(
...,
min_length=3,
max_length=20,
regex=r"^[a-zA-Z0-9_]+$"
)This allows only letters, numbers, and underscore.
Built-in Validated Types (Email, URLs, etc.)
Pydantic provides special types that include their own validation logic.
Some common ones:
| Type | Validates |
|---|---|
EmailStr | email format |
AnyUrl | scheme, host, optional port, path |
HttpUrl | HTTP or HTTPS URL |
IPvAnyAddress | IPv4 or IPv6 address |
PaymentCardNumber | credit card number patterns |
Example:
from pydantic import BaseModel, EmailStr, AnyUrl
class Contact(BaseModel):
email: EmailStr
website: AnyUrl | None = NoneIf the client sends:
{"email": "not-an-email", "website": "htp:/example"}both fields fail validation.
Using these types is much better than writing your own regex for common formats.
Validating Path and Query Parameters
Validation works not only for request bodies, but also for:
- Path parameters.
- Query parameters.
- Headers and cookies (covered in other chapters).
Basic type validation
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None, limit: int = 10):
return {
"item_id": item_id,
"q": q,
"limit": limit
}FastAPI will:
- Convert
item_idfrom the path to an int. - Convert
limitfrom the query to an int. - Give validation errors if they are not valid integers.
Example:
GET /items/abcreturns422becauseabcis not an int.GET /items/1?limit=xyzreturns422becausexyzis not an int.
Adding constraints to query parameters
Use Query for query parameters and Path for path parameters.
from fastapi import Query, Path
@app.get("/search")
def search_items(
q: str = Query(..., min_length=3, max_length=50),
limit: int = Query(10, ge=1, le=100)
):
return {"q": q, "limit": limit}qis required because of....qmust be 3 to 50 characters.limitdefault is 10, and must be between 1 and 100.
Example invalid request:
GET /search?q=ab&limit=1000
qtoo short.limittoo large.
For path parameters:
@app.get("/users/{user_id}")
def get_user(
user_id: int = Path(..., gt=0)
):
return {"user_id": user_id}
/users/-1 will fail because user_id must be greater than 0.
Custom Validation with `@validator`
Sometimes you need rules that are more complex than simple min/max or regex. For that, use Pydantic validators.
There are two main styles:
- Field validators (run for a single field).
- Root validators (work with multiple fields together).
Field validator example
Validate that a password has a minimum complexity:
from pydantic import BaseModel, validator
class UserRegister(BaseModel):
username: str
password: str
@validator("password")
def password_strength(cls, value: str) -> str:
if len(value) < 8:
raise ValueError("Password must be at least 8 characters long")
if value.isdigit() or value.isalpha():
raise ValueError("Password must contain letters and numbers")
return valueUse it in FastAPI:
from fastapi import FastAPI
app = FastAPI()
@app.post("/register")
def register(user: UserRegister):
return {"message": "Registered"}If the client sends:
{"username": "bob", "password": "1234567"}the response will explain that the password is too short.
Cross-field validation with `root_validator`
You may want to validate relationships between fields. For example, start_date must be before end_date.
from datetime import date
from pydantic import BaseModel, root_validator
class Booking(BaseModel):
start_date: date
end_date: date
@root_validator
def check_dates(cls, values):
start = values.get("start_date")
end = values.get("end_date")
if start and end and start > end:
raise ValueError("start_date must be before end_date")
return valuesInvalid example:
{"start_date": "2024-12-10", "end_date": "2024-12-01"}The error will mention the model as a whole, because this is cross-field validation.
Rule: Use @validator for a single field. Use @root_validator when you need to compare multiple fields or enforce rules between them.
Nested Models and Deep Validation
Models can contain other models. Validation works recursively.
from typing import List
from pydantic import BaseModel, EmailStr
class Address(BaseModel):
street: str
city: str
country: str
class User(BaseModel):
username: str
email: EmailStr
addresses: List[Address]Use in a FastAPI endpoint:
@app.post("/users")
def create_user(user: User):
return userExample valid JSON:
{
"username": "alice",
"email": "alice@example.com",
"addresses": [
{"street": "Main St 1", "city": "London", "country": "UK"},
{"street": "Second St 2", "city": "Paris", "country": "France"}
]
}
If one address is missing city:
{
"addresses": [
{"street": "No City", "country": "UK"}
]
}You get an error at location:
"loc": ["body", "user", "addresses", 0, "city"]This tells you exactly where the problem occurred.
Strict vs Flexible Validation
By default, Pydantic is somewhat forgiving, and tries to convert types where possible. For example, "1" can become 1.
Sometimes you want strict validation. For example, you want to reject "1" if the type is int.
Pydantic v1 provides "strict types" like StrictStr, StrictInt, StrictBool.
from pydantic import BaseModel, StrictInt, StrictStr
class StrictExample(BaseModel):
count: StrictInt
label: StrictStrIf the client sends:
{"count": "1", "label": 123}both fields fail, because:
"1"is not an integer.123is not a string.
Use strict types when you really care about exact types, for example financial amounts, or when you want to detect client bugs early.
Handling Validation Errors
You usually let FastAPI handle validation errors automatically. It returns 422 with a consistent JSON format.
Example error structure:
{
"detail": [
{
"loc": ["body", "user", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
}
]
}Sometimes you may want to customize this behavior. For example, you want a different error format.
You can catch RequestValidationError with an exception handler.
from fastapi import Request, FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"errors": exc.errors(),
"message": "Invalid input data"
},
)Now all validation errors return your custom JSON structure.
Rule: Do not manually parse and validate raw JSON when using FastAPI. Always let FastAPI and Pydantic perform the first layer of validation, then add custom validators only when necessary.
Adding Documentation and Examples
When you use Pydantic models and Field, FastAPI can generate documentation and JSON schemas automatically.
You can add extra information that appears in the docs:
from pydantic import BaseModel, Field
class Item(BaseModel):
name: str = Field(
...,
example="Laptop",
description="Name of the item"
)
price: float = Field(
...,
gt=0,
example=999.99,
description="Price of the item in USD"
)
description: str | None = Field(
None,
max_length=300,
example="A powerful laptop for developers."
)In the generated Swagger UI:
- Users see field descriptions.
- They can click "Example Value" to see your example.
- Constraints like
gt=0andmax_length=300appear in the schema.
This is both validation and documentation in one place.
Practical Patterns for Validation in APIs
A few common patterns when designing FastAPI APIs with validation:
Separate input and output models
You often want different models for input and output. For example, hide password in responses.
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserRead(BaseModel):
id: int
email: EmailStrEndpoint:
@app.post("/users", response_model=UserRead)
def create_user(user: UserCreate):
# Save to database, get new_id
new_id = 1
return UserRead(id=new_id, email=user.email)Validation is applied to the input model. The output model also enforces what you return, which prevents accidental leakage of sensitive fields.
Reuse common models
If multiple endpoints receive the same kind of data, define a single Pydantic model and reuse it, instead of revalidating fields separately in each endpoint.
Validate at boundaries, not inside functions
Do not receive a plain dict and then validate manually. Instead, let the endpoint accept a model, and pass that model deeper into your code.
Bad:
@app.post("/items")
def create_item(payload: dict):
# manual checks here...
...Better:
class ItemCreate(BaseModel):
name: str
price: float
@app.post("/items")
def create_item(item: ItemCreate):
# item is already validated
...Summary
- FastAPI uses Pydantic models for data validation.
- Types and default values decide which fields are required or optional.
- Use
Field,Query, andPathto add constraints. - Use built-in types like
EmailStr,AnyUrl, and strict types when needed. - Use
@validatorand@root_validatorto implement custom rules. - Nested models are validated recursively and give precise error locations.
- FastAPI returns structured
422errors for validation problems. - Designing clear, validated models at your API boundaries makes your backend safer, simpler, and easier to maintain.
Views: 9
KAHIBARO