FastAPI · lesson 4 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 Request Bodies and Response Models
Read JSON request bodies into Pydantic models, see the real 422 errors, and use response_model to stop a password field leaking out of a FastAPI API.
By SkillByExample · Published
The Body Is Where Clients Send Data
A path or query parameter holds a single value. To send a whole object, such as a new product with a name, price and tags, the client puts JSON in the request body, the part of the request after the headers. GET requests normally have no body; POST, PUT and PATCH do.
FastAPI reads bodies through Pydantic models: a class listing the fields you expect and their types. The outputs below come from FastAPI 0.143.0 and Pydantic 2.14.1 on Python 3.12. Run each file with uvicorn <file>:app.
A Model for the Body
Save as body.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
quantity: int = 1
tags: list[str] = []
@app.post("/items")
def create_item(item: Item):
total = item.price * item.quantity
return {"item": item, "total": total}
curl -d sends a body and implies POST, so -X POST isn’t needed:
$ curl -i http://127.0.0.1:8000/items -H 'content-type: application/json' -d '{"name": "Mug", "price": 4.5, "quantity": 3}'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 06:57:46 GMT
server: uvicorn
content-length: 71
content-type: application/json
{"item":{"name":"Mug","price":4.5,"quantity":3,"tags":[]},"total":13.5}
Because item is typed as a Pydantic model, FastAPI knows it comes from the body, unlike int or str arguments, which come from the path or query. Inside the function item is an object, so you write item.price, not item["price"]. tags wasn’t sent, so it got its default []. Returning the model inside a dict works too: FastAPI turns it back into JSON.
Pydantic converts compatible values and drops unknown keys:
$ curl -s http://127.0.0.1:8000/items -H 'content-type: application/json' -d '{"name": "Mug", "price": "4.5", "quantity": "3", "colour": "red"}'
{"item":{"name":"Mug","price":4.5,"quantity":3,"tags":[]},"total":13.5}
The strings "4.5" and "3" became a float and an int, and colour vanished without an error. Both are defaults you can change: extra="forbid" rejects unknown keys and strict mode refuses strings for numbers (covered in Pydantic Validation).
The Ways a Body Can Fail
$ curl -s http://127.0.0.1:8000/items -H 'content-type: application/json' -d '{"name": "Mug", "quantity": "three"}'
{"detail":[{"type":"missing","loc":["body","price"],"msg":"Field required","input":{"name":"Mug","quantity":"three"}},{"type":"int_parsing","loc":["body","quantity"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"three"}]}
Both problems are reported in one 422: price is missing, and quantity isn’t a number. loc is ["body", "<field>"], so a client knows exactly which field to fix.
A body that isn’t valid JSON fails earlier, before any field is checked:
$ curl -s http://127.0.0.1:8000/items -H 'content-type: application/json' -d '{"name": "Mug", "price": 4.5,}'
{"detail":[{"type":"json_invalid","loc":["body",29],"msg":"JSON decode error","input":{},"ctx":{"error":"Expecting property name enclosed in double quotes"}}]}
JSON doesn’t allow a trailing comma. loc holds the character position, 29, where the parser gave up.
The most confusing failure comes from forgetting the header:
$ curl -s http://127.0.0.1:8000/items -d '{"name": "Mug", "price": 4.5}'
{"detail":[{"type":"model_attributes_type","loc":["body"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"{\"name\": \"Mug\", \"price\": 4.5}"}]}
$ curl -s -X POST http://127.0.0.1:8000/items
{"detail":[{"type":"missing","loc":["body"],"msg":"Field required","input":null}]}
The JSON is valid, but without content-type: application/json, curl -d labels the body as form data (application/x-www-form-urlencoded). FastAPI doesn’t parse it as JSON, so the model receives one long string, shown escaped in input. The content-type header is how a client tells the server what format the body is in. HTTP libraries such as requests and httpx set it for you when you pass json=. The last request sent no body at all, and the whole body is missing.
The Password Leak
A common first API echoes back what it stored. Save as leak.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserIn(BaseModel):
username: str
email: str
password: str
@app.post("/users", status_code=201)
def create_user(user: UserIn):
# pretend we saved it to a database here
return user
$ curl -i http://127.0.0.1:8000/users -H 'content-type: application/json' -d '{"username": "ada", "email": "ada@example.com", "password": "hunter2"}'
HTTP/1.1 201 Created
date: Tue, 13 Oct 2026 06:57:53 GMT
server: uvicorn
content-length: 65
content-type: application/json
{"username":"ada","email":"ada@example.com","password":"hunter2"}
The password went straight back out in the response, where logs, browser tools and caches can all see it. The fix is a second model for what goes out, passed as response_model. Save as safe.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserIn(BaseModel):
username: str
email: str
password: str
class UserOut(BaseModel):
username: str
email: str
@app.post("/users", status_code=201, response_model=UserOut)
def create_user(user: UserIn):
return user
@app.post("/v2/users", status_code=201)
def create_user_v2(user: UserIn) -> UserOut:
return UserOut(username=user.username, email=user.email)
$ curl -i http://127.0.0.1:8000/users -H 'content-type: application/json' -d '{"username": "ada", "email": "ada@example.com", "password": "hunter2"}'
HTTP/1.1 201 Created
date: Tue, 13 Oct 2026 06:57:54 GMT
server: uvicorn
content-length: 44
content-type: application/json
{"username":"ada","email":"ada@example.com"}
$ curl -s http://127.0.0.1:8000/v2/users -H 'content-type: application/json' -d '{"username": "ada", "email": "ada@example.com", "password": "hunter2"}'
{"username":"ada","email":"ada@example.com"}
create_user still returns the full UserIn with its password, but FastAPI passes the return value through UserOut and keeps only its fields. content-length dropped from 65 to 44 bytes. The /v2 route shows the other way to declare it, a return type annotation -> UserOut, which FastAPI treats the same way. Use response_model= when the function returns something of a different type, as create_user does; a type checker would flag the -> UserOut version if it returned a UserIn.
The response model is also published in the API docs:
$ curl -s http://127.0.0.1:8000/openapi.json | python -c "import json,sys; s=json.load(sys.stdin); print(s['paths']['/users']['post']['responses']['201']['content']['application/json']['schema'])"
{'$ref': '#/components/schemas/UserOut'}
When Your Response Breaks Its Own Model
The response model is checked on the way out, too. Save as broken.py:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Product(BaseModel):
id: int
name: str
price: float
@app.get("/products/{product_id}", response_model=Product)
def get_product(product_id: int):
return {"id": product_id, "name": "Kettle"}
$ curl -i http://127.0.0.1:8000/products/1
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 06:57:56 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8
Internal Server Error
The client did nothing wrong, so this isn’t a 422. The server promised a price and didn’t deliver one, which is a server bug: a 500. The details are only in the Uvicorn log. Here is its end, after a long run of uvicorn, starlette and fastapi frames:
File "C:\tmp\fapi-b1\venv\Lib\site-packages\fastapi\routing.py", line 331, in serialize_response
raise ResponseValidationError(
fastapi.exceptions.ResponseValidationError: 1 validation error:
{'type': 'missing', 'loc': ('response', 'price'), 'msg': 'Field required', 'input': {'id': 1, 'name': 'Kettle'}}
File "C:\tmp\fapi-b1\l4\broken.py", line 13, in get_product
GET /products/{product_id}
loc starts with response instead of body, and FastAPI names the route that produced it: line 13 of broken.py, the @app.get decorator. This check is a safety net. It stops a half-built object from reaching clients who rely on the documented shape.
Practice
- Add
GET /usersthat returns a list of stored user dicts, passwords included, and make sure no password leaves the API. Hint:response_modelacceptslist[...].
Solution
USERS = [
{"username": "ada", "email": "ada@example.com", "password": "hunter2"},
{"username": "alan", "email": "alan@example.com", "password": "enigma"},
]
@app.get("/users", response_model=list[UserOut])
def list_users():
return USERS
$ curl -s http://127.0.0.1:8000/users
[{"username":"ada","email":"ada@example.com"},{"username":"alan","email":"alan@example.com"}]
Each dict in the list is filtered through UserOut. The response model works on plain dicts as well as models.
- Write
PUT /items/{item_id}that takes anItembody (name,price) plus an optionalnotifyquery flag, and returns all three. How does FastAPI know where each argument comes from?
Solution
@app.put("/items/{item_id}")
def update_item(item_id: int, item: Item, notify: bool = False):
return {"item_id": item_id, "item": item, "notify": notify}
$ curl -s -X PUT 'http://127.0.0.1:8000/items/5?notify=true' -H 'content-type: application/json' -d '{"name": "Mug", "price": 4.5}'
{"item_id":5,"item":{"name":"Mug","price":4.5,"quantity":1,"tags":[]},"notify":true}
item_id appears in the path template, so it’s a path parameter. item is a Pydantic model, so it’s the body. notify is a plain type not in the path, so it’s a query parameter.
- Fix
broken.pyso/products/1succeeds without inventing a price.
Solution
Make the field optional in the response model:
class Product(BaseModel):
id: int
name: str
price: float | None = None
$ curl -s http://127.0.0.1:8000/products/1
{"id":1,"name":"Kettle","price":null}
The missing price is now sent as an explicit null, which the docs advertise, so clients know to expect it.