KAHIBARO
Discord Login Register

8.5. Data Validation

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:

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:

python
from pydantic import BaseModel
class UserCreate(BaseModel):
    username: str
    email: str
    age: int

Use it in an endpoint:

python
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:

Example invalid request:

json
{
  "username": "alice",
  "email": 123,
  "age": "twenty"
}

Response:

json
{
  "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.

python
class UserCreate(BaseModel):
    username: str          # required
    email: str             # required
    age: int               # required

If the client omits age:

json
{"username": "alice", "email": "alice@example.com"}

Response:

json
{
  "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.

python
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:

json
{
  "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+):

python
class UserCreate(BaseModel):
    username: str
    email: str
    age: int | None = None

Rule: A field is optional only if:

  • The type includes None (for example Optional[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:

python
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:

python
age: Optional[int] = None

Basic Type Validation

Pydantic validates the value and often tries to coerce types where possible.

Type annotationExample valid inputNotes
int"5", 5Tries to cast strings like "5" to int
float"3.14", 3.14, 5Casts where possible
str123, "hello"Converts other types to string
booltrue, 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:

python
from typing import List, Dict
from pydantic import BaseModel
class Order(BaseModel):
    id: int
    items: List[str]
    quantities: Dict[str, int]

Valid JSON:

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:

json
{"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:

Using `Field` with constraints

python
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 0

Explanation:

Example invalid request:

json
{
  "name": "TV",
  "price": -10,
  "stock": -5
}

Validation errors will show each violated constraint.

Common Field constraints for numbers:

ArgumentMeaning
gtgreater than
gegreater than or equal
ltless than
leless than or equal

Common Field constraints for strings / lists:

ArgumentMeaning
min_lengthminimum number of characters
max_lengthmaximum number of characters
regexmust match regular expression

Example with regex:

python
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:

TypeValidates
EmailStremail format
AnyUrlscheme, host, optional port, path
HttpUrlHTTP or HTTPS URL
IPvAnyAddressIPv4 or IPv6 address
PaymentCardNumbercredit card number patterns

Example:

python
from pydantic import BaseModel, EmailStr, AnyUrl
class Contact(BaseModel):
    email: EmailStr
    website: AnyUrl | None = None

If the client sends:

json
{"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:

Basic type validation

python
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:

Example:

Adding constraints to query parameters

Use Query for query parameters and Path for path parameters.

python
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}

Example invalid request:

GET /search?q=ab&limit=1000

For path parameters:

python
@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 validator example

Validate that a password has a minimum complexity:

python
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 value

Use it in FastAPI:

python
from fastapi import FastAPI
app = FastAPI()
@app.post("/register")
def register(user: UserRegister):
    return {"message": "Registered"}

If the client sends:

json
{"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.

python
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 values

Invalid example:

json
{"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.

python
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:

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

Example valid JSON:

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:

json
{
  "addresses": [
    {"street": "No City", "country": "UK"}
  ]
}

You get an error at location:

json
"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.

python
from pydantic import BaseModel, StrictInt, StrictStr
class StrictExample(BaseModel):
    count: StrictInt
    label: StrictStr

If the client sends:

json
{"count": "1", "label": 123}

both fields fail, because:

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:

json
{
  "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.

python
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:

python
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:

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.

python
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
    email: EmailStr
    password: str
class UserRead(BaseModel):
    id: int
    email: EmailStr

Endpoint:

python
@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:

python
@app.post("/items")
def create_item(payload: dict):
    # manual checks here...
    ...

Better:

python
class ItemCreate(BaseModel):
    name: str
    price: float
@app.post("/items")
def create_item(item: ItemCreate):
    # item is already validated
    ...

Summary

Views: 9

Comments

Please login to add a comment.

Don't have an account? Register now!