FastAPI: Building REST API with Automatic Validation

1. Overview

When building a REST API, much of the “defensive” code lies in validating input: is the field required, is the number negative, is the email valid. FastAPI delegates this to Pydantic. You just declare data types and constraints, and the framework automatically validates requests and returns structured 422 errors.

This article builds an Order management API from scratch. After reading, you’ll know how to constrain body, path, and query parameters. You’ll also be able to write custom validators, filter returned data, customize error formatting, and test everything with pytest.

2. Installation

You need Python 3.10 or higher. Create a virtualenv and install FastAPI with the standard dependency group:

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest

The fastapi[standard] package already includes everything this article needs:

  • uvicorn for running the server.
  • fastapi-cli provides the fastapi dev command.
  • httpx for TestClient.
  • email-validator for the EmailStr type.

3. First endpoint with Pydantic model

Create a file main.py:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Order API")


class OrderCreate(BaseModel):
    customer_email: str
    product_sku: str
    quantity: int


@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
    return order

Run the server in dev mode with auto-reload:

fastapi dev main.py

There’s not a single if statement here. The parameter order: OrderCreate tells FastAPI to do three things:

  • Read JSON from the request body.
  • Parse the JSON into OrderCreate.
  • Return 422 if the data doesn’t match the type.

Try sending quantity as a string instead of a number:

curl -X POST localhost:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}

The loc field precisely points to the error location: in body, at the quantity field. Moreover, the OpenAPI documentation at /docs is generated from the model itself. Code and documentation therefore stay in sync.

4. Constraining data with Field

Type checking alone isn’t enough. For example, quantity = -5 is still a valid int. Add constraints using Field combined with Annotated:

from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, EmailStr, Field

Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]


class OrderCreate(BaseModel):
    customer_email: EmailStr
    product_sku: Sku
    quantity: Annotated[int, Field(gt=0, le=100)]
    unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
    discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
    note: Annotated[str | None, Field(max_length=200)] = None

Each line does the following:

  • EmailStr validates actual email format, not just checking for the @ character.
  • Sku is a reusable type. It requires 3–20 characters and contains only letters, numbers, and hyphens.
  • quantity must be greater than 0 and not exceed 100.
  • unit_price uses Decimal instead of float to avoid rounding errors in monetary calculations. Max 10 digits, 2 decimal places.
  • discount and note have default values, so they’re optional fields. Any field without a default is required.

Sending quantity: 0 returns:

{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}

Note: By default, Pydantic runs in lax mode. The string "5" will be coerced to the number 5 without error. If you want to reject it outright, add model_config = ConfigDict(strict=True) to the model or use Field(strict=True) for individual fields.

5. Validating path and query parameters

Not just body, but path and query parameters are validated using the same mechanism:

from fastapi import HTTPException, Path, Query

orders_db: dict[int, dict] = {}


@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
    if order_id not in orders_db:
        raise HTTPException(status_code=404, detail="Order not found")
    return orders_db[order_id]


@app.get("/orders")
def list_orders(
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
    sku: Annotated[str | None, Query(min_length=3)] = None,
):
    items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
    start = (page - 1) * size
    return items[start : start + size]

Results with different requests:

  • GET /orders/0 returns 422, because order_id must be greater than 0.
  • GET /orders?size=500 returns 422, because size is limited to 50 maximum.
  • GET /orders?page=2&size=5 works normally. Both page and size values are coerced to int before entering the function.

Distinguish between 422 and 404. 422 means the request is malformed. 404 is business logic, so you raise HTTPException yourself.

6. Custom validators

Some rules can’t be expressed with Field. Use field_validator for single-field rules or model_validator for rules involving multiple fields.

from pydantic import field_validator, model_validator

FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}


class OrderCreate(BaseModel):
    # ... fields as in section 4 ...

    @field_validator("product_sku")
    @classmethod
    def normalize_sku(cls, value: str) -> str:
        return value.upper()

    @field_validator("customer_email")
    @classmethod
    def reject_disposable_email(cls, value: str) -> str:
        domain = value.split("@")[-1].lower()
        if domain in FORBIDDEN_DOMAINS:
            raise ValueError("Disposable email is not allowed")
        return value

    @model_validator(mode="after")
    def check_discount(self) -> "OrderCreate":
        total = self.unit_price * self.quantity
        if self.discount > total:
            raise ValueError("Discount cannot exceed order total")
        return self

Explanation of each validator:

  • normalize_sku runs after Field has validated the pattern. It normalizes the SKU to uppercase, so "tshirt-01" becomes "TSHIRT-01". Validators can transform values as long as they return the new value.
  • reject_disposable_email raises ValueError. Pydantic converts this to a validation error, and the message gets a "Value error, " prefix.
  • check_discount runs with mode="after", meaning when all fields are valid and have the right types. This way you can safely multiply Decimal values. Remember to return self.

Note: Don’t call databases or external APIs in validators. For example, checking “does this SKU exist in inventory” is business logic. Put it in a service or dependency. Validators should be pure functions, fast, and easy to test.

7. response_model: controlling returned data

You often store more information than you want to return to the client, such as internal notes. Separate input and output models:

class OrderRead(BaseModel):
    id: int
    customer_email: EmailStr
    product_sku: str
    quantity: int
    total: Decimal


@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
    order_id = len(orders_db) + 1
    record = {
        "id": order_id,
        **order.model_dump(),
        "total": order.unit_price * order.quantity - order.discount,
        "internal_flag": "manual-review",
    }
    orders_db[order_id] = record
    return record

The function returns a dict with both note and internal_flag. However, response_model=OrderRead filters the response to only declared fields:

{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}

Two things to note:

  • Decimal is serialized as a string "39.98" to preserve precision. The client needs to be aware of this.
  • The response is also validated. If you forget the total field, FastAPI raises ResponseValidationError and returns 500, not 422. This is a server error, not a client error.

8. Customizing the 422 error format

The default format is quite verbose. Many teams want a consistent, concise structure for the frontend. Override the RequestValidationError handler:

from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "field": ".".join(str(part) for part in err["loc"][1:]) or "body",
            "message": err["msg"],
        }
        for err in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"code": "VALIDATION_FAILED", "errors": errors},
    )

How the handler processes each error:

  • err["loc"][1:] removes the first element (body, query, path) and joins the rest with dots.
  • Errors from model_validator don’t attach to any field. In that case, loc is just ("body",), so the handler assigns "body".

Sending an order with quantity: 0 and a disposable email returns:

{"code":"VALIDATION_FAILED","errors":[
  {"field":"customer_email","message":"Value error, Disposable email is not allowed"},
  {"field":"quantity","message":"Input should be greater than 0"}]}

Pydantic collects all field errors at once, rather than stopping at the first one. This way, the client can fix everything in one go.

9. Testing with pytest

TestClient calls the app directly without running a server. Create test_main.py next to main.py:

import pytest
from fastapi.testclient import TestClient

from main import app, orders_db

client = TestClient(app)

VALID_ORDER = {
    "customer_email": "an@example.com",
    "product_sku": "tshirt-01",
    "quantity": 2,
    "unit_price": "19.99",
}


@pytest.fixture(autouse=True)
def clear_db():
    orders_db.clear()


def test_create_order_returns_normalized_sku_and_total():
    response = client.post("/orders", json=VALID_ORDER)

    assert response.status_code == 201
    body = response.json()
    assert body["product_sku"] == "TSHIRT-01"
    assert body["total"] == "39.98"
    assert "internal_flag" not in body


def test_invalid_quantity_and_email_are_reported_together():
    payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    fields = {e["field"] for e in response.json()["errors"]}
    assert fields == {"quantity", "customer_email"}


def test_discount_greater_than_total_is_rejected():
    payload = {**VALID_ORDER, "discount": "100.00"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    assert response.json()["errors"][0] == {
        "field": "body",
        "message": "Value error, Discount cannot exceed order total",
    }


def test_page_size_over_limit_is_rejected():
    assert client.get("/orders?size=500").status_code == 422

Run tests:

pytest -q
....                                                    [100%]
4 passed in 0.31s

The clear_db fixture resets orders_db before each test, keeping tests independent. Also, you can test models in isolation without HTTP using OrderCreate.model_validate(...) combined with pytest.raises(ValidationError). This works well when models have complex rules.

10. Common mistakes

  • Using float for money. 0.1 + 0.2 doesn’t equal 0.3. Use Decimal with max_digits and decimal_places.
  • Forgetting @classmethod or decorator order. @field_validator must come above @classmethod. Otherwise, Pydantic v2 raises an error at import time.
  • Forgetting return in a validator. A field_validator without a return assigns None to the field. model_validator(mode="after") must return self.
  • Using Pydantic v1 syntax. @validator, class Config, .dict() are deprecated. Use @field_validator, model_config, .model_dump().
  • Confusing 422 with 500. Invalid input returns 422. Response mismatching response_model returns 500. See 500? Check your returned data.
  • Using the same model for input and output. This risks exposing internal fields and forces clients to send fields like id. Separate OrderCreate and OrderRead.

Note: API-layer validation doesn’t replace database constraints. Constraints like UNIQUE and NOT NULL are still necessary. Requests can enter the system through multiple paths—background jobs, migration scripts, etc.

11. Conclusion

FastAPI combined with Pydantic turns type hints into data contracts with three key benefits:

  • Invalid requests are blocked at the gate.
  • Errors are returned in full and are well-structured.
  • OpenAPI documentation always matches the code.

In this article, you used Field for simple constraints, Path and Query for URL parameters, field_validator and model_validator for custom rules. You also used response_model to filter output, customize 422 error formatting, and test everything with TestClient.

Remember: let validators check data shape, and put business logic in the service layer.

pythonfastapipydanticrest-apivalidation