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:
uvicornfor running the server.fastapi-cliprovides thefastapi devcommand.httpxforTestClient.email-validatorfor theEmailStrtype.
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
422if 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:
EmailStrvalidates actual email format, not just checking for the@character.Skuis a reusable type. It requires 3–20 characters and contains only letters, numbers, and hyphens.quantitymust be greater than 0 and not exceed 100.unit_priceusesDecimalinstead offloatto avoid rounding errors in monetary calculations. Max 10 digits, 2 decimal places.discountandnotehave 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 number5without error. If you want to reject it outright, addmodel_config = ConfigDict(strict=True)to the model or useField(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/0returns422, becauseorder_idmust be greater than 0.GET /orders?size=500returns422, becausesizeis limited to 50 maximum.GET /orders?page=2&size=5works normally. Bothpageandsizevalues are coerced tointbefore 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_skuruns afterFieldhas validated the pattern. It normalizes the SKU to uppercase, so"tshirt-01"becomes"TSHIRT-01". Validators can transform values as long as theyreturnthe new value.reject_disposable_emailraisesValueError. Pydantic converts this to a validation error, and the message gets a"Value error, "prefix.check_discountruns withmode="after", meaning when all fields are valid and have the right types. This way you can safely multiplyDecimalvalues. Remember toreturn 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:
Decimalis 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
totalfield, FastAPI raisesResponseValidationErrorand returns500, not422. 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_validatordon’t attach to any field. In that case,locis 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
floatfor money.0.1 + 0.2doesn’t equal0.3. UseDecimalwithmax_digitsanddecimal_places. - Forgetting
@classmethodor decorator order.@field_validatormust come above@classmethod. Otherwise, Pydantic v2 raises an error at import time. - Forgetting
returnin a validator. Afield_validatorwithout a return assignsNoneto the field.model_validator(mode="after")must returnself. - Using Pydantic v1 syntax.
@validator,class Config,.dict()are deprecated. Use@field_validator,model_config,.model_dump(). - Confusing
422with500. Invalid input returns422. Response mismatchingresponse_modelreturns500. See500? 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. SeparateOrderCreateandOrderRead.
Note: API-layer validation doesn’t replace database constraints. Constraints like
UNIQUEandNOT NULLare 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.