FastAPI · lesson 7 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6FastAPI Pydantic Validation: Field Constraints and Nested Models
Required vs optional fields, Field constraints, nested models, extra='forbid' and strict mode in FastAPI, each shown with the real 422 body it produces.
By SkillByExample · Published
Types Are the First Check, Not the Only One
Request Bodies and Response Models used Pydantic models to check that price is a number. Real APIs need more: a price above zero, a SKU in a fixed format, an order with at least one line, and no unexpected keys. All of that goes on the model, and FastAPI turns every violation into a 422 before your function runs.
The outputs below come from FastAPI 0.143.0, Pydantic 2.14.1 and Starlette 1.7.0 on Python 3.12. Each file calls its own app through TestClient, so you run it with plain python; it needs pip install httpx2, which TestClient uses to send requests.
Required, Optional, and Nullable
Save as fields.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Signup(BaseModel):
username: str # required
nickname: str | None # required, but may be null
bio: str | None = None # optional, defaults to None
newsletter: bool = False # optional, defaults to False
app = FastAPI()
@app.post("/signup")
def signup(s: Signup):
return s
client = TestClient(app)
for body in [{"username": "ada"}, {"username": "ada", "nickname": None}]:
r = client.post("/signup", json=body)
print(r.status_code, r.json())
$ python fields.py
422 {'detail': [{'type': 'missing', 'loc': ['body', 'nickname'], 'msg': 'Field required', 'input': {'username': 'ada'}}]}
200 {'username': 'ada', 'nickname': None, 'bio': None, 'newsletter': False}
str | None reads like “optional”, but it only says the value may be null. What makes a field optional is a default. nickname has none, so leaving it out is a 422, while sending "nickname": null is fine. bio and newsletter have defaults, so they can be left out. Ask two questions per field: may the key be missing (give it a default), and may the value be null (add | None).
Field Constraints
Field() attaches rules to a field: gt/ge/lt/le for numbers, min_length/max_length for strings and lists, and pattern for a regular expression. Save as constraints.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class Product(BaseModel):
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
name: str = Field(min_length=2, max_length=20)
price: float = Field(gt=0)
stock: int = Field(default=0, ge=0, le=1000)
app = FastAPI()
@app.post("/products")
def create(p: Product):
return p
client = TestClient(app)
print(client.post("/products", json={"sku": "MUG-0001", "name": "Mug", "price": 4.5}).json())
r = client.post("/products", json={"sku": "mug-1", "name": "M", "price": 0, "stock": 5000})
for err in r.json()["detail"]:
print(r.status_code, err["loc"], err["type"], err.get("ctx"))
$ python constraints.py
{'sku': 'MUG-0001', 'name': 'Mug', 'price': 4.5, 'stock': 0}
422 ['body', 'sku'] string_pattern_mismatch {'pattern': '^[A-Z]{3}-\\d{4}$'}
422 ['body', 'name'] string_too_short {'min_length': 2}
422 ['body', 'price'] greater_than {'gt': 0.0}
422 ['body', 'stock'] less_than_equal {'le': 1000}
The second request broke four rules and got all four back in one 422, one entry per field. The script prints each entry on its own line; the full entries also carry msg and input as in the first example. Each type names the rule that failed, and ctx holds its limit, so a client can build its own message such as “at least 2 characters” without parsing English. Field(default=0, ...) shows how to combine a default with constraints. gt=0 became 0.0 in ctx because the field is a float.
Nested Models
A field can itself be a model, or a list of models. Save as nested.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class Line(BaseModel):
sku: str
qty: int = Field(ge=1)
class Address(BaseModel):
city: str
postcode: str
class Order(BaseModel):
ship_to: Address
lines: list[Line] = Field(min_length=1)
app = FastAPI()
@app.post("/orders")
def create(order: Order):
return {"city": order.ship_to.city, "units": sum(l.qty for l in order.lines)}
client = TestClient(app)
ok = {"ship_to": {"city": "Leeds", "postcode": "LS1"}, "lines": [{"sku": "MUG", "qty": 2}]}
print(client.post("/orders", json=ok).json())
bad = {"ship_to": {"city": "Leeds"}, "lines": [{"sku": "MUG", "qty": 2}, {"sku": "TEA", "qty": 0}]}
for err in client.post("/orders", json=bad).json()["detail"]:
print(err["loc"], err["msg"])
$ python nested.py
{'city': 'Leeds', 'units': 2}
['body', 'ship_to', 'postcode'] Field required
['body', 'lines', 1, 'qty'] Input should be greater than or equal to 1
Inside the function, order.ship_to.city and l.qty are attributes on real objects, not dictionary lookups. When validation fails, loc walks down the JSON: ['body', 'lines', 1, 'qty'] is the qty of the second line (index 1). A frontend can use that path to highlight the exact input. Field(min_length=1) on a list counts items, so an order with "lines": [] is rejected.
Rejecting Unknown Keys with extra="forbid"
Pydantic drops keys it doesn’t know. That hides client typos. Save as forbid.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
class Loose(BaseModel):
name: str
is_admin: bool = False
class Tight(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
is_admin: bool = False
app = FastAPI()
@app.post("/loose")
def loose(user: Loose):
return user
@app.post("/tight")
def tight(user: Tight):
return user
client = TestClient(app)
body = {"name": "ada", "isAdmin": True}
print(client.post("/loose", json=body).json())
print(client.post("/tight", json=body).json())
$ python forbid.py
{'name': 'ada', 'is_admin': False}
{'detail': [{'type': 'extra_forbidden', 'loc': ['body', 'isAdmin'], 'msg': 'Extra inputs are not permitted', 'input': True}]}
The client meant to create an admin and wrote isAdmin instead of is_admin. /loose answered 200 with is_admin: False, so the client believes it succeeded, and the bug surfaces much later. /tight refuses the request and names the unknown key. model_config is how a Pydantic model takes settings; ConfigDict just gives them type hints.
Strict Mode, and Its Trap
By default Pydantic converts compatible values: "2" becomes 2. Strict mode turns that off. The obvious way is to make the whole model strict. Save as strict.py:
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
class Booking(BaseModel):
model_config = ConfigDict(strict=True)
guests: int
day: date
app = FastAPI()
@app.post("/bookings")
def book(b: Booking):
return b
client = TestClient(app)
for body in [{"guests": "2", "day": "2026-10-13"}, {"guests": 2, "day": "2026-10-13"}]:
r = client.post("/bookings", json=body)
print(r.status_code, [(e["loc"][-1], e["msg"]) for e in r.json()["detail"]])
print(Booking.model_validate_json('{"guests": 2, "day": "2026-10-13"}'))
$ python strict.py
422 [('guests', 'Input should be a valid integer'), ('day', 'Input should be a valid date')]
422 [('day', 'Input should be a valid date')]
guests=2 day=datetime.date(2026, 10, 13)
The string "2" is rejected, as intended. But the second request is perfectly good, and it fails too: "2026-10-13" is “not a valid date”. JSON has no date type, so a date always arrives as a string. FastAPI parses the JSON into a Python dict first and then validates the dict, and in strict mode a Python string is not a date. The last line shows the same string passing model_validate_json, where Pydantic knows the input is JSON. A model-wide strict=True makes dates, datetimes and UUIDs unusable in a FastAPI body.
The fix is to be strict per field, only where it matters. Save as strict_fields.py:
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, StrictBool
class Booking(BaseModel):
guests: int = Field(strict=True, ge=1)
day: date
vip: StrictBool = False
app = FastAPI()
@app.post("/bookings")
def book(b: Booking):
return b
client = TestClient(app)
for body in [
{"guests": 2, "day": "2026-10-13"},
{"guests": "2", "day": "2026-10-13", "vip": "yes"},
]:
r = client.post("/bookings", json=body)
print(r.status_code, r.json())
$ python strict_fields.py
200 {'guests': 2, 'day': '2026-10-13', 'vip': False}
422 {'detail': [{'type': 'int_type', 'loc': ['body', 'guests'], 'msg': 'Input should be a valid integer', 'input': '2'}, {'type': 'bool_type', 'loc': ['body', 'vip'], 'msg': 'Input should be a valid boolean', 'input': 'yes'}]}
The date is converted as usual, while guests and vip refuse strings. Non-strict, "yes" would have become True, which is rarely what a client meant. StrictBool is a ready-made type equal to Annotated[bool, Field(strict=True)]; StrictInt, StrictStr and StrictFloat also exist.
Practice
Field(min_length=2)accepts" ", three spaces. MakeProduct.namestrip surrounding whitespace before the length check, so" Mug "is stored as"Mug"and" "fails.
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class Product(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
name: str = Field(min_length=2, max_length=20)
app = FastAPI()
@app.post("/products")
def create(p: Product):
return p
client = TestClient(app)
for name in [" Mug ", " "]:
r = client.post("/products", json={"name": name})
print(r.status_code, r.json())
$ python p1.py
200 {'name': 'Mug'}
422 {'detail': [{'type': 'string_too_short', 'loc': ['body', 'name'], 'msg': 'String should have at least 2 characters', 'input': ' ', 'ctx': {'min_length': 2}}]}
str_strip_whitespace=True strips every string field in the model before its constraints run. The error’s input still shows what the client sent.
- A blog
Posthastags: at most 3 tags, each 1 to 10 characters. Constrain both the list and each item.
Solution
from typing import Annotated
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
Tag = Annotated[str, Field(min_length=1, max_length=10)]
class Post(BaseModel):
title: str
tags: list[Tag] = Field(default=[], max_length=3)
app = FastAPI()
@app.post("/posts")
def create(p: Post):
return p
client = TestClient(app)
print(client.post("/posts", json={"title": "Hi", "tags": ["python", "web"]}).json())
for tags in [["a", "b", "c", "d"], ["python", "", "webdevelopment"]]:
for err in client.post("/posts", json={"title": "Hi", "tags": tags}).json()["detail"]:
print(err["loc"], err["msg"])
$ python p2.py
{'title': 'Hi', 'tags': ['python', 'web']}
['body', 'tags'] List should have at most 3 items after validation, not 4
['body', 'tags', 1] String should have at least 1 character
['body', 'tags', 2] String should have at most 10 characters
Field() on tags constrains the list. The item rules go inside the type with Annotated, and Tag can be reused in any model. The item errors carry the index of the bad tag in loc.
- Add
extra="forbid"toOrderin a cut-downnested.py, then send an unknown key at the top level and another inside a line. Are both rejected?
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
class Line(BaseModel):
sku: str
qty: int
class Order(BaseModel):
model_config = ConfigDict(extra="forbid")
lines: list[Line]
app = FastAPI()
@app.post("/orders")
def create(order: Order):
return order
client = TestClient(app)
print(client.post("/orders", json={"lines": [], "coupon": "FREE"}).json())
print(client.post("/orders", json={"lines": [{"sku": "MUG", "qty": 1, "price": 0}]}).json())
$ python p3.py
{'detail': [{'type': 'extra_forbidden', 'loc': ['body', 'coupon'], 'msg': 'Extra inputs are not permitted', 'input': 'FREE'}]}
{'lines': [{'sku': 'MUG', 'qty': 1}]}
No. The config belongs to Order only, so price inside a line was silently dropped. Put model_config = ConfigDict(extra="forbid") on Line too, or on a base class that every model inherits from.
- Add an optional
discountbetween 0 and 0.5 to aQuotewith anamountabove zero. Leaving it out and sendingnullshould both mean “no discount”.
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class Quote(BaseModel):
amount: float = Field(gt=0)
discount: float | None = Field(default=None, ge=0, le=0.5)
app = FastAPI()
@app.post("/quotes")
def quote(q: Quote):
return {"total": q.amount * (1 - (q.discount or 0))}
client = TestClient(app)
for body in [{"amount": 100}, {"amount": 100, "discount": None},
{"amount": 100, "discount": 0.2}, {"amount": 100, "discount": 0.9}]:
r = client.post("/quotes", json=body)
print(r.status_code, r.json())
$ python p4.py
200 {'total': 100.0}
200 {'total': 100.0}
200 {'total': 80.0}
422 {'detail': [{'type': 'less_than_equal', 'loc': ['body', 'discount'], 'msg': 'Input should be less than or equal to 0.5', 'input': 0.9, 'ctx': {'le': 0.5}}]}
The constraints apply only when a number is sent; None skips them. default=None makes the key optional and | None allows an explicit null.