DEV Community

Cover image for FastAPI + Pydantic: A Practical Guide to Data Validation
Moshfiqur Rahman
Moshfiqur Rahman

Posted on

FastAPI + Pydantic: A Practical Guide to Data Validation

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install FastAPI and Pydantic's email support:

pip install "fastapi[standard]" "pydantic[email]"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Item model has three fields:

  • name is a required string.
  • price is a required number.
  • in_stock is a boolean. It defaults to True, 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}'
Enter fullscreen mode Exit fullscreen mode

You will get:

{
  "name": "Keyboard",
  "price": 49.99,
  "in_stock": true
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Here is what each rule does:

  • min_length and max_length set the allowed length of a string or collection.
  • pattern checks a string against a regular expression.
  • ge means greater than or equal to.
  • le means less than or equal to.
  • gt and lt mean greater than and less than.
  • multiple_of checks 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

  • UUID checks that the value is a valid UUID.
  • EmailStr validates an email address.
  • HttpUrl validates a URL.
  • Role only accepts the values defined in the enum.
  • Literal limits theme to "light" or "dark".
  • SecretStr helps 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()
Enter fullscreen mode Exit fullscreen mode

There are three details worth remembering:

  1. Put @classmethod below @field_validator.
  2. Raise ValueError when the value does not pass validation. Do not raise HTTPException inside a Pydantic validator.
  3. 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
Enter fullscreen mode Exit fullscreen mode

Now the model can accept either of these inputs:

{
  "title": "Learning FastAPI",
  "tags": "python, fastapi"
}
Enter fullscreen mode Exit fullscreen mode
{
  "title": "Learning FastAPI",
  "tags": ["python", "fastapi"]
}
Enter fullscreen mode Exit fullscreen mode

For production code, prefer a factory for collection defaults:

tags: list[str] = Field(default_factory=list)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • user_id must be greater than zero.
  • q must contain between 2 and 50 characters.
  • limit must be between 1 and 100. If the client does not provide it, the default is 10.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

These two settings do the following:

  • str_strip_whitespace=True removes 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
Enter fullscreen mode Exit fullscreen mode

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},
    )
Enter fullscreen mode Exit fullscreen mode

The response will look like this:

{
  "errors": [
    {
      "field": "username",
      "message": "String should have at least 3 characters"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, @validator was replaced by @field_validator, .dict() by .model_dump(), and orm_mode by from_attributes.
  • Forgetting to return a value from a validator. The returned value becomes the field value.
  • Raising HTTPException inside a validator. Raise ValueError and 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) or Field(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, and Enum are 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 ValueError when 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)