Skip to main content
FastAPI · lesson 7 of 19
FastAPI intermediate Lesson 7 of 19

FastAPI 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

fastapi python pydantic validation 422

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

  1. Field(min_length=2) accepts " ", three spaces. Make Product.name strip 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.

  1. A blog Post has tags: 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.

  1. Add extra="forbid" to Order in a cut-down nested.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.

  1. Add an optional discount between 0 and 0.5 to a Quote with an amount above zero. Leaving it out and sending null should 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.

Frequently Asked Questions

What is the difference between Optional and a default value in a Pydantic model?
A field is optional only if it has a default. 'nickname: str | None' with no default is still required: the client must send the key, although it may send null. 'bio: str | None = None' can be left out entirely. In FastAPI a missing required field produces a 422 with type 'missing'.
How do I add constraints like minimum, maximum or regex to a FastAPI request body?
Use pydantic.Field on the model field: Field(gt=0) or ge/lt/le for numbers, Field(min_length=2, max_length=20) for strings and lists, and Field(pattern=r'...') for a regular expression. A violation returns a 422 whose error type names the rule, such as greater_than or string_pattern_mismatch, and whose ctx holds the limit. The constraints also appear in the OpenAPI schema as minimum, maximum and pattern.
Does extra='forbid' on a Pydantic model apply to nested models?
No. model_config = ConfigDict(extra='forbid') applies only to the model it is set on. A nested model keeps the default behaviour and silently drops unknown keys. Set extra='forbid' on every nested model too, or on a shared base class they all inherit from.
Why does FastAPI reject a valid date string when the model uses strict=True?
FastAPI parses the JSON body into Python objects first and then validates them, and in strict mode Pydantic only accepts a real date object for a date field in Python mode. The string '2026-10-13' is therefore rejected with 'Input should be a valid date', even though Model.model_validate_json would accept it. Make individual fields strict with Field(strict=True) or StrictInt/StrictBool instead of the whole model.
What does the loc field mean in a FastAPI 422 error?
loc is the path to the bad value. It starts with where the value came from, such as body, query or path, then lists keys and list indexes down to the field. ['body', 'lines', 1, 'qty'] means the qty of the second item in the lines list of the request body.