Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Request bodies & Pydantic v2

A request body is declared as a Pydantic model . FastAPI reads the JSON, Pydantic validates it against the model, and your function receives a typed object, or the…

In this post

What this post covers
  1. 1. A body model, nested models, and the 422 you get for free
  2. 2. Validators: normalise values, enforce cross-field rules
  3. 3. Lax vs strict: what Pydantic will coerce
  4. 4. extra=“forbid”: stop mass assignment
  5. 5. One resource, several models
  6. 6. camelCase JSON, snake_case Python
  7. 7. Content-Type is checked (FastAPI 0.132+)
  8. Recap

A request body is declared as a Pydantic model. FastAPI reads the JSON, Pydantic validates it against the model, and your function receives a typed object, or the client receives a precise 422 explaining which field is wrong and why. The model is the contract, so senior teams put real care into it: tight types and constraints, cross-field rules, separate models for create, update and read, and forbidding fields the client should never set.

🛃

Customs at the border

Everything entering the country is checked against a declaration form: allowed items, quantities, paperwork that must agree (an arrival date after the departure date). Anything undeclared is refused, not waved through. Inside the country, nobody has to re-check it. Pydantic models are that border for your service.

1. A body model, nested models, and the 422 you get for free

nested models and precise error locations

from decimal import Decimal

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

class LineItem(BaseModel):
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{2}$")
    qty: int = Field(gt=0, le=50)
    unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

class OrderCreate(BaseModel):
    customer_id: int
    items: list[LineItem] = Field(min_length=1, max_length=100)
    note: str | None = Field(None, max_length=500)

app = FastAPI()

@app.post("/orders", status_code=201)
def create_order(order: OrderCreate):
    total = sum(i.qty * i.unit_price for i in order.items)
    return {"customer_id": order.customer_id, "lines": len(order.items), "total": str(total)}

client = TestClient(app)
good = {"customer_id": 7, "items": [{"sku": "TEA-01", "qty": 2, "unit_price": "120.50"}]}
print(client.post("/orders", json=good).json())

bad = {"customer_id": "seven", "items": [{"sku": "tea", "qty": 0, "unit_price": "12.345"}]}
for err in client.post("/orders", json=bad).json()["detail"]:
    print(f"  {'.'.join(map(str, err['loc'])):28} {err['msg']}")

Output

{'customer_id': 7, 'lines': 1, 'total': '241.00'}
  body.customer_id             Input should be a valid integer, unable to parse string as an integer
  body.items.0.sku             String should match pattern '^[A-Z]{3}-\d{2}$'
  body.items.0.qty             Input should be greater than 0
  body.items.0.unit_price      Decimal input should have no more than 2 decimal places

Every error has a location (body.items.0.qty), a machine-readable type and a human message. Clients can map errors straight onto form fields. Part 09 shows how to reshape this into your own error format without losing the detail.

2. Validators: normalise values, enforce cross-field rules

field_validator and model_validator

from datetime import date

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, field_validator, model_validator

class Booking(BaseModel):
    guest_email: str
    check_in: date
    check_out: date
    promo_code: str | None = None

    @field_validator("guest_email")
    @classmethod
    def normalise_email(cls, v: str) -> str:
        v = v.strip().lower()
        if "@" not in v or v.startswith("@") or v.endswith("@"):
            raise ValueError("not a valid email address")
        return v

    @field_validator("promo_code")
    @classmethod
    def upper_promo(cls, v: str | None) -> str | None:
        return v.strip().upper() if v else None

    @model_validator(mode="after")                     # runs after all fields are validated
    def dates_in_order(self):
        if self.check_out <= self.check_in:
            raise ValueError("check_out must be after check_in")
        if (self.check_out - self.check_in).days > 30:
            raise ValueError("stays longer than 30 nights must be booked by phone")
        return self

app = FastAPI()

@app.post("/bookings")
def book(b: Booking):
    return b

client = TestClient(app)
print(client.post("/bookings", json={"guest_email": "  [email protected] ", "check_in": "2026-10-01",
                                     "check_out": "2026-10-04", "promo_code": " diwali "}).json())
r = client.post("/bookings", json={"guest_email": "[email protected]", "check_in": "2026-10-04",
                                   "check_out": "2026-10-01"})
print(r.status_code, r.json()["detail"][0]["msg"])

Output

{'guest_email': '[email protected]', 'check_in': '2026-10-01', 'check_out': '2026-10-04', 'promo_code': 'DIWALI'}
422 Value error, check_out must be after check_in
Email addresses

Pydantic ships EmailStr, which uses the email-validator package (installed by fastapi[standard]). This site's machine does not have it, so the example hand-rolls a minimal check. In real code, prefer EmailStr. It handles internationalised addresses correctly.

3. Lax vs strict: what Pydantic will coerce

By default Pydantic is lax: it converts sensible inputs, so "5" becomes 5 and "true" becomes True. That is friendly for query strings, where everything is text. For JSON bodies from other services you may want strict types, where a string is never silently accepted as a number:

lax by default, strict when you ask

from typing import Annotated

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field, StrictInt

class Lax(BaseModel):
    qty: int
    gift: bool

class Strict(BaseModel):
    model_config = ConfigDict(strict=True)       # the whole model is strict
    qty: int
    gift: bool

class Mixed(BaseModel):
    qty: StrictInt                               # just this field is strict
    discount: Annotated[float, Field(ge=0, le=0.9)]

app = FastAPI()

@app.post("/lax")
def lax(body: Lax):
    return body

@app.post("/strict")
def strict(body: Strict):
    return body

client = TestClient(app)
payload = {"qty": "5", "gift": "yes"}
print("lax:   ", client.post("/lax", json=payload).json())
print("strict:", [e["msg"] for e in client.post("/strict", json=payload).json()["detail"]])
print(Mixed.model_validate({"qty": 3, "discount": "0.25"}))

Output

lax:    {'qty': 5, 'gift': True}
strict: ['Input should be a valid integer', 'Input should be a valid boolean']
qty=3 discount=0.25

4. extra=“forbid”: stop mass assignment

By default, unknown fields in the JSON are ignored. That hides client bugs, and it hides attacks when code later copies the whole payload into a database row. If a client sends "is_admin": true and someone writes User(**payload), you have a privilege escalation (OWASP API3: broken object property level authorization). Forbid extras on input models:

unknown fields: ignored by default, rejected with forbid

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict

class SignupLoose(BaseModel):
    email: str
    name: str

class Signup(BaseModel):
    model_config = ConfigDict(extra="forbid")
    email: str
    name: str

app = FastAPI()

@app.post("/loose")
def loose(body: SignupLoose):
    return {"received": body.model_dump()}

@app.post("/strict")
def strict(body: Signup):
    return {"received": body.model_dump()}

client = TestClient(app)
attack = {"email": "[email protected]", "name": "Eve", "is_admin": True, "credits": 1_000_000}
print("default:", client.post("/loose", json=attack).json())                     # silently dropped
r = client.post("/strict", json=attack)
print("forbid: ", r.status_code, [(e["loc"][-1], e["type"]) for e in r.json()["detail"]])

Output

default: {'received': {'email': '[email protected]', 'name': 'Eve'}}
forbid:  422 [('is_admin', 'extra_forbidden'), ('credits', 'extra_forbidden')]

5. One resource, several models

OrderCreate (in)customer_id, items,noteextra="forbid" OrderUpdate (PATCH)note: str | None = Nonestatus: … | None = Noneapply only fields sent OrderRead (out)id, status, total,created_at, itemsserver-generated fields DB model (internal)cost_price,fraud_score, …never returned One model for everything leaks internal fields out and lets clients write server-owned fields in.

PATCH: apply only the fields the client actually sent

from typing import Literal

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict

class OrderUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")
    note: str | None = None
    status: Literal["pending", "paid", "cancelled"] | None = None

ORDERS = {1: {"id": 1, "note": "leave at door", "status": "pending"}}
app = FastAPI()

@app.patch("/orders/{order_id}")
def patch_order(order_id: int, patch: OrderUpdate):
    changes = patch.model_dump(exclude_unset=True)       # ONLY the fields present in the request
    ORDERS[order_id] |= changes
    return {"applied": changes, "order": ORDERS[order_id]}

client = TestClient(app)
print(client.patch("/orders/1", json={"status": "paid"}).json())       # note is untouched
print(client.patch("/orders/1", json={"note": None}).json())           # explicitly clearing a field

Output

{'applied': {'status': 'paid'}, 'order': {'id': 1, 'note': 'leave at door', 'status': 'paid'}}
{'applied': {'note': None}, 'order': {'id': 1, 'note': None, 'status': 'paid'}}

exclude_unset=True is the difference between “the client did not mention note” (keep it) and “the client set note to null” (clear it). Using exclude_none instead makes it impossible to clear a field, a subtle and common PATCH bug.

6. camelCase JSON, snake_case Python

alias generators

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class ApiModel(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

class ShippingAddress(ApiModel):
    full_name: str
    postal_code: str
    is_default: bool = False

app = FastAPI()

@app.post("/addresses", response_model=ShippingAddress)       # responses use aliases by default
def add_address(addr: ShippingAddress):
    print("inside Python:", addr.full_name, addr.postal_code)
    return addr

client = TestClient(app)
print("on the wire:  ", client.post("/addresses", json={"fullName": "Asha K", "postalCode": "682001"}).json())

Output

inside Python: Asha K 682001
on the wire:   {'fullName': 'Asha K', 'postalCode': '682001', 'isDefault': False}

7. Content-Type is checked (FastAPI 0.132+)

Since 0.132, FastAPI only parses a body as JSON when the request declares a JSON Content-Type. Browsers can send cross-site “simple” requests without a JSON content type and without a CORS preflight, so parsing such bodies as JSON opened a CSRF path, especially for services on localhost. Clients that genuinely omit the header can be accommodated with FastAPI(strict_content_type=False). Enable that only deliberately.

same bytes, with and without a JSON content type

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

class Ping(BaseModel):
    msg: str

app = FastAPI()

@app.post("/ping")
def ping(body: Ping):
    return body

client = TestClient(app)
raw = b'{"msg": "hi"}'
print(client.post("/ping", content=raw, headers={"Content-Type": "application/json"}).status_code)
r = client.post("/ping", content=raw, headers={"Content-Type": "text/plain"})
print(r.status_code, r.json()["detail"][0]["msg"])

Output

200
422 Input should be a valid dictionary or object to extract fields from

Recap

  • Bodies are Pydantic models; invalid input returns 422 with a location, type and message per error.
  • Constrain everything (Field, patterns, list lengths, Decimal precision); validators normalise and enforce cross-field rules.
  • Lax by default, strict when needed; extra="forbid" on input models stops mass assignment.
  • Separate Create / Update / Read models; PATCH with exclude_unset; aliases for camelCase APIs; JSON bodies need a JSON Content-Type.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries