Request Body with Pydantic
Receive JSON from the client and get a checked Python object back: describe the shape once with a Pydantic model, and FastAPI does the rest. Tested with 20 requests - including a typo that silently set a price to 0.0, and the one-line fix.
What you will be able to do
- Explain what a request body is and when to use one
- Describe a JSON body with a Pydantic model: required, optional and default fields
- Read the 422 errors a body produces
- Know which conversions Pydantic does on its own
- Combine a body with path and query parameters
- Stop typos with extra="forbid"
The idea, in plain English
Path and query parameters are small values in the URL. But to create a book you send much more: a title, an author, the number of pages, a price, a summary. That data goes in the request body - usually as JSON, sent with POST, PUT or PATCH.
In FastAPI you describe the body once, as a Pydantic model: a class with typed fields. Then you use the model as the type of a function argument. FastAPI reads the JSON, checks every field against your model, and gives your function a real Python object - or answers 422 with a list of every problem.
In this lesson we build POST /books and send it 20 different bodies: good ones, broken ones, and tricky ones. One result is a real trap: a field name with a typo was silently ignored, and the price became 0.0. FastAPI 0.143.0, Pydantic 2.14.0.
Worked example: POST /books with a BookIn model: good data, missing fields, wrong types, extra fields, a typo, form data, and body + path + query in one request.
1 - The client sends JSON
{"title": "Web APIs", "author": "Asha", "pages": 210}, with the header Content-Type: application/json. Without that header, FastAPI refused it (422).
POST /books, step by step. Real results.
Words you will see in this lesson
A few words about bodies and models.
Request bodyThe data a client sends with the request - usually JSON, with POST, PUT or PATCH.PydanticThe library FastAPI uses to check data against typed classes.ModelA class that inherits from BaseModel and lists fields with types.FieldOne named value in a model: title: str.Content-TypeA header saying what format the body is: application/json.model_dump()Turns a model object into a normal dict.An everyday example: a form at the bank
When you open a bank account you fill in a printed form: name, date of birth, address, phone (optional). The clerk checks it before doing anything: every required box filled? Date in the right format? If not, the form comes back with each problem marked.
A Pydantic model is that printed form. The fields are the boxes, the types are the rules, defaults are the boxes you may leave empty. FastAPI is the clerk: it checks the JSON against the form and returns every problem at once.
Example 1 - a model and POST /books
BookIn lists what a client must send. title, author and pages have no default, so they are required. price has a default (0.0) and summary may be None, so both are optional.
create_book(book: BookIn) is all FastAPI needs: an argument whose type is a Pydantic model is read from the JSON body. Inside, book is a real object - book.title, book.pages. We return it, its type name, and model_dump() so you can see each form.
The other routes show more cases: body + path + query together, two body parameters, and Body(embed=True) for a single value.
from typing import Annotated
from fastapi import Body, FastAPI
from pydantic import BaseModel
app = FastAPI()
class BookIn(BaseModel): # the shape of the JSON the client must send
title: str # required
author: str # required
pages: int # required, a whole number
price: float = 0.0 # optional, with a default
summary: str | None = None # optional, may be null
@app.post("/books", status_code=201)
def create_book(book: BookIn): # a Pydantic model -> read from the JSON body
return {"received": book, "type": type(book).__name__, "as_dict": book.model_dump()}
@app.put("/shops/{shop_id}/books/{book_id}")
def update_book(shop_id: int, book_id: int, book: BookIn, notify: bool = False):
return {"shop_id": shop_id, "book_id": book_id, "notify": notify, "title": book.title}
class Review(BaseModel):
stars: int
text: str
@app.post("/books/{book_id}/review")
def add_review(book_id: int, review: Review, reviewer: Annotated[str, Body()]):
return {"book_id": book_id, "review": review, "reviewer": reviewer}
@app.post("/books/{book_id}/rating")
def rate(book_id: int, rating: Annotated[int, Body(embed=True)]):
return {"book_id": book_id, "rating": rating}good -> 201 {"received":{"title":"Web APIs","author":"Asha","pages":210,"price":0.0,"summary":null},"type":"BookIn","as_dict":{"title":"Web APIs","author":"Asha","pages":210,"price":0.0,"summary":null}}What Pydantic accepts, converts and refuses
We changed one thing at a time. Missing author: 422 "Field required" at ["body", "author"]. Missing author AND pages: one 422 with TWO entries - Pydantic reports every problem at once, so the client can fix them all in one go.
Conversions: pages as the text "210" was accepted as 210, and 210.0 as 210 - the meaning is clear. 210.5 was refused: "got a number with a fractional part". summary: null was fine, because the field allows None.
Extra fields: we sent "isbn": "123", which BookIn does not have. No error - it was simply dropped. Keep that in mind; the next section shows why it matters.
all fields -> 201 {"received":{"title":"Web APIs","author":"Asha","pages":210,"price":19.5,"summary":"A short book"},...
pages as text '210' -> 201 {"received":{"title":"Web APIs","author":"Asha","pages":210,...
pages 210.0 -> 201 {"received":{"title":"Web APIs","author":"Asha","pages":210,...
pages 210.5 -> 422 {"detail":[{"type":"int_from_float","loc":["body","pages"],"msg":"Input should be a valid integer, got a number with a fractional part","input":210.5}]}
extra field -> 201 {"received":{"title":"Web APIs","author":"Asha","pages":210,"price":0.0,"summary":null},... (isbn dropped)
missing author -> 422 {"detail":[{"type":"missing","loc":["body","author"],"msg":"Field required","input":{"title":"Web APIs","pages":210}}]}
two problems -> 422 {"detail":[{"type":"missing","loc":["body","author"],...},{"type":"missing","loc":["body","pages"],...
summary null -> 201 {"received":{...,"summary":null},...The typo trap - and the fix
Here is why dropped fields matter. A client sends {"title": "Web APIs", "prise": 19.5} - "prise" is a typo for "price". FastAPI drops "prise", uses the default price 0.0, and answers 200. The book is saved for free, and nobody sees an error.
One line fixes it: model_config = ConfigDict(extra="forbid"). Now unknown fields are an error: 422 "Extra inputs are not permitted" at ["body", "prise"]. The client sees the typo at once. Use it for models that receive data from clients, especially when fields have defaults.
from pydantic import BaseModel, ConfigDict
class Loose(BaseModel):
title: str
price: float = 0.0
class Strict(BaseModel):
model_config = ConfigDict(extra="forbid") # unknown fields are an error
title: str
price: float = 0.0
# @app.post("/loose") takes Loose, @app.post("/strict") takes Strict
typo = {"title": "Web APIs", "prise": 19.5} # "prise" - a typo for "price"/loose -> 200 {"title":"Web APIs","price":0.0}
/strict -> 422 {"detail":[{"type":"extra_forbidden","loc":["body","prise"],"msg":"Extra inputs are not permitted","input":19.5}]}Watch out: The loose version answered 200 and stored price 0.0. No error, no warning - the most expensive kind of bug. extra="forbid" turns it into a clear 422.
It must really be JSON
A model body must arrive as a JSON object with the JSON content type. We tried three other ways and all three got 422 "Input should be a valid dictionary or object to extract fields from".
Form data (like an HTML form sends) is not JSON - Module 3 shows how to receive forms. JSON text sent WITHOUT the header Content-Type: application/json was refused too: the header tells FastAPI how to read the body. And a JSON list [ ... ] is not an object. With TestClient and httpx, json=... sets the header for you; with curl, add -H "Content-Type: application/json" (Lesson 1.3).
no body -> 422 {"detail":[{"type":"missing","loc":["body"],"msg":"Field required","input":null}]}
form data, not JSON -> 422 {"detail":[{"type":"model_attributes_type","loc":["body"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"title=Web+APIs&author=Asha&pages=210"}]}
JSON text, no header -> 422 {"detail":[{"type":"model_attributes_type","loc":["body"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"{\"title\": \"Web APIs\", \"author\": \"Asha\",
a list, not an object -> 422 {"detail":[{"type":"model_attributes_type","loc":["body"],"msg":"Input should be a valid dictionary or object to extract fields from","input":[{"title":"Web APIs","author":"Asha","pages":210Body, path and query together
One request can use all three. In update_book, shop_id and book_id are in the path pattern, so they come from the path. book is a Pydantic model, so it comes from the body. notify is a simple type that is not in the path, so it comes from the query. PUT /shops/4/books/2?notify=true with a JSON body filled all four.
That is FastAPI’s rule: in the path pattern -> path; a Pydantic model -> body; a simple type otherwise -> query.
PUT /shops/4/books/2?notify=true + body -> 200 {"shop_id":4,"book_id":2,"notify":true,"title":"Web APIs"}Name appears in "{...}" in the pathPath parameter.Type is a Pydantic modelRequest body.Simple type (int, str, bool...) otherwiseQuery parameter.Marked with Body(), Query(), Path()Wherever you say.Two body values, and embed
add_review has two body parameters: a Review model and a reviewer string marked with Body(). With more than one, FastAPI expects each under its own name: {"review": {...}, "reviewer": "Ravi"}. Sending the review fields flat - {"stars": 5, "text": "Great", "reviewer": "Ravi"} - gave 422 "Field required" at ["body", "review"].
Body(embed=True) does the same for a single value: rate expects {"rating": 4}, not just 4. Sending the bare number 4 was refused. embed is useful when you want the JSON to have a clear name, even for one value.
two body params -> 200 {"book_id":2,"review":{"stars":5,"text":"Great"},"reviewer":"Ravi"}
two bodies, flat (wrong) -> 422 {"detail":[{"type":"missing","loc":["body","review"],"msg":"Field required","input":null}]}
embed=True -> 200 {"book_id":2,"rating":4}
embed=True, bare 4 -> 422 {"detail":[{"type":"missing","loc":["body","rating"],"msg":"Field required","input":null}]}Request bodies at a glance
A modelTyped fields.
class BookIn(BaseModel):
title: str
price: float = 0.0Use itA model argument = the body.
def create_book(book: BookIn):
Optional fieldA default, or None.
summary: str | None = None
Back to a dictFor saving or returning.
book.model_dump()
Refuse unknown fieldsCatches typos.
model_config = ConfigDict(extra="forbid")
One value with a name{"rating": 4}
rating: Annotated[int, Body(embed=True)]
Send JSON with curlThe header is required.
curl -X POST url -H "Content-Type: application/json" -d '{...}'Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Open /docs for body.py, expand POST /books, and look at the "Schema" tab. Where do required fields show?”
“In /docs, send a body with pages as "two hundred". Read the 422 entry.”
“Add extra="forbid" to BookIn. Send the "isbn" field again. What changed?”
“Make create_book return a message like "Web APIs by Asha, 210 pages" using book.title, book.author and book.pages.”
What usually goes wrong
A typo ("prise") set the price to the default 0.0 with no error.
✗ class BookIn(BaseModel):
price: float = 0.0✓ class BookIn(BaseModel):
model_config = ConfigDict(extra="forbid")
price: float = 0.0JSON text without Content-Type: application/json got 422.
✗ curl -X POST url -d '{"title": "x"}'✓ curl -X POST url -H "Content-Type: application/json" -d '{"title": "x"}'Bodies belong to POST, PUT and PATCH. Many clients and proxies drop or refuse a GET body; use query parameters for GET.
With two body parameters, each goes under its own name.
✗ {"stars": 5, "text": "Great", "reviewer": "Ravi"}✓ {"review": {"stars": 5, "text": "Great"}, "reviewer": "Ravi"}Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Write a model OrderIn for POST /orders with: product (required text), quantity (required whole number), gift (optional true/false, default False) and note (optional text, may be missing). Refuse unknown fields.
Show hintHide hint
Four fields with types and defaults, plus model_config = ConfigDict(extra="forbid").
Key points
- A request body carries the data of POST, PUT and PATCH - usually JSON.
- Describe it with a Pydantic model; an argument with that type is read from the body.
- Fields without a default are required; a default or | None makes them optional.
- Every problem is reported at once in a 422, with loc = ["body", "field"].
- Pydantic converts when the meaning is clear ("210" -> 210) and refuses otherwise (210.5 for an int).
- Unknown fields are dropped silently - extra="forbid" turns typos into errors.
- Path pattern -> path; a model -> body; other simple types -> query.
Quick check before you move on
Interview questions
How does FastAPI handle request bodies?
Parameters typed as Pydantic models are read from the JSON body, validated against the model (types, required fields, constraints), converted to model instances, and documented in OpenAPI; failures return 422 with all errors.
What does extra="forbid" do and when should you use it?
It rejects fields not defined in the model instead of ignoring them. Use it for client input so typos and unexpected data fail loudly, particularly where fields have defaults.
How does Pydantic lax mode affect input?
It coerces values when unambiguous - numeric strings to numbers, 210.0 to 210 - but rejects lossy conversions. Strict mode can disable coercion where exact types matter.
Quiz
- 1.
A body is missing two required fields. How many 422 entries do you get?
- 2.
Why did JSON text without a Content-Type header fail?
- 3.
In update_book, where does notify come from, and why?
- 4.
What JSON does rating: Annotated[int, Body(embed=True)] expect?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...