When you build an API, you cannot assume that every request will contain the data you expect. A user might send an empty username, an invalid email address, or a negative age.
You could write if checks for every field in every endpoint. But as your application grows, that approach becomes repetitive and easy to miss.
FastAPI works with Pydantic to handle this for you. You define the shape of the data and the rules it must follow. FastAPI then validates incoming requests before passing the data to your endpoint.
In this guide, we'll use Pydantic v2 and go through the validation features you'll commonly need in a FastAPI project.
1. What happens when a request arrives?
Let's say your API receives this JSON:
{
"username": "",
"email": "not-an-email",
"age": -5
}
These values are not valid for a typical user account. Without validation, they could reach your business logic or database and cause problems later.
FastAPI and Pydantic work together to prevent that.
- FastAPI handles routes, request processing, dependency injection, and API documentation.
- Pydantic checks and parses data based on Python type hints.
When a request arrives, FastAPI passes its body to Pydantic. If the data is valid, your endpoint receives a typed Python object. If validation fails, FastAPI returns a 422 Unprocessable Entity response with details about the invalid fields.
Your endpoint does not need to handle the invalid request itself.
2. Set up the project
The examples in this article use Python 3.10 or newer.
Create and activate a virtual environment:
python -m venv .venv
# Linux or macOS
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
Install FastAPI and Pydantic's email support:
pip install "fastapi[standard]" "pydantic[email]"
We install pydantic[email] because the EmailStr type uses the email-validator package.
Create a main.py file and start the development server:
fastapi dev main.py
Open http://127.0.0.1:8000/docs to try the endpoints in Swagger UI.
3. Create your first Pydantic model
A Pydantic model defines the data your endpoint expects.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
in_stock: bool = True
@app.post("/items")
def create_item(item: Item):
return item
The Item model has three fields:
-
nameis a required string. -
priceis a required number. -
in_stockis a boolean. It defaults toTrue, so the client does not have to send it.
FastAPI reads the request body and validates it against this model.
For example, send this request:
curl -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Keyboard", "price": 49.99}'
You will get:
{
"name": "Keyboard",
"price": 49.99,
"in_stock": true
}
Notice that we did not send in_stock. Pydantic used the default value.
4. How Pydantic handles different data types
By default, Pydantic uses lax mode. This means it can convert some input values when the conversion is possible.
For example, a client may send the price as a string:
curl -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Mouse", "price": "19.99"}'
Pydantic can convert "19.99" into the float 19.99.
But it cannot convert "abc" into a number:
curl -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Mouse", "price": "abc"}'
FastAPI returns a validation error similar to this:
{
"detail": [
{
"type": "float_parsing",
"loc": ["body", "price"],
"msg": "Input should be a valid number, unable to parse string as a number",
"input": "abc"
}
]
}
The response tells you:
-
type: what kind of validation failed. -
loc: where the invalid value was found. -
msg: why the value was rejected. -
input: the value that caused the error.
This information is useful when you're debugging an API or showing validation feedback in a frontend application.
5. Add field rules with Field()
Type hints tell Pydantic what kind of value a field should contain. They do not always describe the limits of that value.
For example, a username should not be empty or excessively long. You can add those rules with Field().
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[
str,
Field(
min_length=3,
max_length=20,
pattern=r"^[a-zA-Z0-9_]+$",
),
]
email: EmailStr
age: Annotated[int, Field(ge=13, le=120)]
bio: Annotated[str | None, Field(max_length=200)] = None
Here is what each rule does:
-
min_lengthandmax_lengthset the allowed length of a string or collection. -
patternchecks a string against a regular expression. -
gemeans greater than or equal to. -
lemeans less than or equal to. -
gtandltmean greater than and less than. -
multiple_ofchecks whether a number is a multiple of a given value.
You can also use default and default_factory to provide default values. For example, default_factory is useful when each model instance needs its own list or a newly generated timestamp.
For a simple model, you can write the same constraints in a shorter form:
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
age: int = Field(ge=13, le=120)
Field() also accepts metadata such as description, examples, and alias. These can make your generated API documentation more useful.
If a client sends a username with only two characters, Pydantic rejects it and includes the minimum length in the error details.
6. Use Pydantic's built-in types
Before writing a custom validator, check whether Pydantic already provides a type for the value you need.
from datetime import datetime
from enum import Enum
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field, HttpUrl, SecretStr
class Role(str, Enum):
user = "user"
admin = "admin"
class Profile(BaseModel):
id: UUID
email: EmailStr
website: HttpUrl | None = None
role: Role = Role.user
theme: Literal["light", "dark"] = "light"
password: SecretStr
created_at: datetime = Field(default_factory=datetime.now)
A few things to notice:
-
UUIDchecks that the value is a valid UUID. -
EmailStrvalidates an email address. -
HttpUrlvalidates a URL. -
Roleonly accepts the values defined in the enum. -
Literallimitsthemeto"light"or"dark". -
SecretStrhelps prevent a secret value from being exposed when the model is printed or represented.
Literal and Enum are useful when a field can only contain a fixed set of values. FastAPI also uses these definitions when generating the API documentation.
7. Write custom validation with field_validator
Built-in types and Field() cover many common cases. Sometimes, though, you need a rule that is specific to your application.
Use @field_validator when the rule applies to one field.
For example, you may want to prevent users from choosing reserved usernames:
from pydantic import BaseModel, EmailStr, field_validator
class UserCreate(BaseModel):
username: str
email: EmailStr
@field_validator("username")
@classmethod
def username_not_reserved(cls, value: str) -> str:
reserved_names = {"admin", "root", "support"}
if value.lower() in reserved_names:
raise ValueError("This username is reserved")
return value.lower()
There are three details worth remembering:
- Put
@classmethodbelow@field_validator. - Raise
ValueErrorwhen the value does not pass validation. Do not raiseHTTPExceptioninside a Pydantic validator. - Return the value. Pydantic uses the returned value as the final field value.
In this example, the username is converted to lowercase before it is stored in the model.
8. Choose between before and after validation
A field validator can run before or after Pydantic's standard type validation.
-
mode="after"is the default. Pydantic checks and converts the value first, then your validator receives the typed value. This is usually a good choice for business rules. -
mode="before"runs before type validation. Your validator receives the raw input, so it is useful when you need to clean or transform incoming data.
For example, suppose your API normally expects tags as a list, but you also want to accept a comma-separated string.
from pydantic import BaseModel, field_validator
class Post(BaseModel):
title: str
tags: list[str] = []
@field_validator("tags", mode="before")
@classmethod
def split_tags(cls, value):
if isinstance(value, str):
return [
tag.strip()
for tag in value.split(",")
if tag.strip()
]
return value
Now the model can accept either of these inputs:
{
"title": "Learning FastAPI",
"tags": "python, fastapi"
}
{
"title": "Learning FastAPI",
"tags": ["python", "fastapi"]
}
For production code, prefer a factory for collection defaults:
tags: list[str] = Field(default_factory=list)
This makes it clear that each model instance gets its own list.
9. Validate multiple fields with model_validator
A field validator is not enough when a rule depends on more than one field.
For example, during signup, the password and confirmation password must match. Use @model_validator for this kind of check.
from typing import Self
from pydantic import BaseModel, EmailStr, Field, model_validator
class UserSignup(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
Because this rule checks the model as a whole, the validation error is attached to the model rather than to a single field. Its location is usually shown as:
{
"detail": [
{
"type": "value_error",
"loc": ["body"],
"msg": "Value error, Passwords do not match"
}
]
}
An after model validator runs only after the individual fields have passed their own validation.
10. Reuse validation rules with Annotated
You may need the same validation rule in several models. Instead of writing the same validator again and again, define a reusable type with Annotated and AfterValidator.
Here is an example that rejects strings containing only whitespace:
from typing import Annotated
from pydantic import AfterValidator, BaseModel
def not_blank(value: str) -> str:
value = value.strip()
if not value:
raise ValueError("Must not be blank")
return value
NonBlankStr = Annotated[str, AfterValidator(not_blank)]
class Article(BaseModel):
title: NonBlankStr
author: NonBlankStr
summary: NonBlankStr
Now title, author, and summary all use the same rule.
If you need to change that rule later, you only have to update it in one place.
11. Validate nested models and lists
Real API requests often contain nested objects. Pydantic can validate those too.
from pydantic import BaseModel, Field
class Address(BaseModel):
city: str
zip_code: str = Field(pattern=r"^\d{4}$")
class Customer(BaseModel):
name: str
addresses: list[Address] = Field(min_length=1)
The Customer model contains a list of Address objects. Pydantic checks each address and reports the exact location of an invalid value.
For example, if the ZIP code in the second address is invalid, the error location can look like this:
["body", "addresses", 1, "zip_code"]
That makes nested validation errors much easier to track down.
12. Validate query and path parameters
Validation is not limited to JSON request bodies. FastAPI also lets you validate path and query parameters with Path() and Query().
from typing import Annotated
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/users/{user_id}")
def get_user(user_id: Annotated[int, Path(gt=0)]):
return {"user_id": user_id}
@app.get("/search")
def search(
q: Annotated[str, Query(min_length=2, max_length=50)],
limit: Annotated[int, Query(ge=1, le=100)] = 10,
):
return {"q": q, "limit": limit}
In this example:
-
user_idmust be greater than zero. -
qmust contain between 2 and 50 characters. -
limitmust be between 1 and 100. If the client does not provide it, the default is10.
You can apply the same idea to headers with FastAPI's Header().
Group related query parameters in a model
If an endpoint has several query parameters, you can group them in a Pydantic model. Recent FastAPI versions support this with Query().
from typing import Annotated, Literal
from fastapi import FastAPI, Query
from pydantic import BaseModel, Field
app = FastAPI()
class ProductFilters(BaseModel):
model_config = {"extra": "forbid"}
limit: int = Field(10, gt=0, le=100)
offset: int = Field(0, ge=0)
order_by: Literal["price", "created_at"] = "created_at"
@app.get("/products")
def list_products(filters: Annotated[ProductFilters, Query()]):
return filters
Setting extra="forbid" makes FastAPI reject query parameters that are not defined in the model. This can help catch mistakes such as sending limt instead of limit.
13. Keep request and response models separate
This is an important part of API design.
A request model describes the data a client is allowed to send. A response model describes the data your API is allowed to return. They often need different fields.
For example, a signup request needs a password. But the API should never return that password or its hash.
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr, Field
app = FastAPI()
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
email: EmailStr
password: str = Field(min_length=8)
class UserResponse(BaseModel):
id: int
username: str
email: EmailStr
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(user: UserCreate):
# In a real application, hash the password and save the user.
saved_user = {
"id": 1,
"username": user.username,
"email": user.email,
"password_hash": "hashed-secret",
}
return saved_user
FastAPI uses response_model to validate and filter the returned data. In this example, the client receives only the fields defined in UserResponse. The internal password_hash field is not included.
This is useful for preventing accidental data exposure. It also documents the response structure in Swagger UI.
14. Configure how a model behaves
Pydantic's model_config lets you set rules that apply to the whole model.
from pydantic import BaseModel, ConfigDict
class UserCreate(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
extra="forbid",
)
username: str
email: str
These two settings do the following:
-
str_strip_whitespace=Trueremoves whitespace from the beginning and end of string values. -
extra="forbid"rejects fields that are not defined in the model.
By default, Pydantic ignores extra fields. With extra="forbid", a typo such as emial will cause a validation error instead of being silently ignored.
If you use an ORM such as SQLAlchemy, from_attributes=True lets Pydantic read values from object attributes:
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
This is useful when you want to create a response model from an ORM object.
15. Customize validation error responses
FastAPI's default 422 response contains useful details. However, your frontend may need a smaller and more consistent error structure.
You can customize the response by registering an exception handler:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request,
exc: RequestValidationError,
):
errors = [
{
"field": ".".join(str(part) for part in error["loc"][1:]),
"message": error["msg"],
}
for error in exc.errors()
]
return JSONResponse(
status_code=422,
content={"errors": errors},
)
The response will look like this:
{
"errors": [
{
"field": "username",
"message": "String should have at least 3 characters"
}
]
}
You can adjust the structure to match the error format used by your frontend. Keep the original validation details available somewhere if you need them for debugging.
16. Test your validation rules
Validation is part of your application's behavior, so it deserves tests.
FastAPI provides TestClient for testing endpoints. Install the required packages:
pip install httpx pytest
Then write tests like these:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_user_success():
response = client.post(
"/users",
json={
"username": "rahim",
"email": "rahim@example.com",
"password": "supersecret123",
},
)
assert response.status_code == 201
assert "password" not in response.json()
def test_username_too_short():
response = client.post(
"/users",
json={
"username": "ab",
"email": "rahim@example.com",
"password": "supersecret123",
},
)
assert response.status_code == 422
The first test checks that a valid request succeeds and that the response does not expose a password. The second checks that a username that is too short is rejected.
Add similar tests for the rules that matter to your application, such as invalid email addresses, missing fields, and values outside an allowed range.
17. Common mistakes to avoid
A few mistakes can make Pydantic validation harder to maintain:
-
Using Pydantic v1 syntax in a v2 project. In Pydantic v2,
@validatorwas replaced by@field_validator,.dict()by.model_dump(), andorm_modebyfrom_attributes. - Forgetting to return a value from a validator. The returned value becomes the field value.
-
Raising
HTTPExceptioninside a validator. RaiseValueErrorand let FastAPI handle the validation error. -
Putting database queries inside validators. Keep validators focused on parsing and validation. Check things such as duplicate email addresses in your service or business logic, then return an appropriate response such as
409 Conflict. - Using the same model for input and output. Separate request and response models so private fields do not leak.
-
Using unclear mutable defaults. For lists and dictionaries,
Field(default_factory=list)orField(default_factory=dict)makes the intention clear. -
Writing custom validators for simple rules. Use built-in types and
Field()when they are enough.
18. A quick review checklist
Before finishing an endpoint, check the following:
- [ ] The request body uses a Pydantic model instead of an unvalidated dictionary.
- [ ] Simple constraints use
Field(). - [ ] Built-in types such as
EmailStr,HttpUrl,Literal, andEnumare used where appropriate. - [ ] Request and response models are separate.
- [ ] The endpoint has a suitable
response_model. - [ ] Passwords, tokens, and other private values are not returned.
- [ ] Custom validators return the validated value and raise
ValueErrorwhen needed. - [ ] Important validation rules have tests.
Final thoughts
FastAPI and Pydantic make request validation part of the normal API workflow. You define the expected data once, and FastAPI uses that model to validate requests and generate API documentation.
You do not need to add custom validators everywhere. Start with type hints and Field(). Add field_validator when a rule applies to one field, and use model_validator when a rule depends on multiple fields.
If you are working on a FastAPI project, try converting one endpoint that currently accepts a plain dictionary into a Pydantic model. Add a few validation rules and test how FastAPI responds to invalid data.
Have you run into an unusual request payload or validation issue in one of your projects? Share it in the comments.





Top comments (0)