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