Skip to main content
FastAPI · lesson 4 of 19
FastAPI beginner Lesson 4 of 19

FastAPI 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

fastapi python pydantic request-body response-model

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

  1. Add GET /users that returns a list of stored user dicts, passwords included, and make sure no password leaves the API. Hint: response_model accepts list[...].
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.

  1. Write PUT /items/{item_id} that takes an Item body (name, price) plus an optional notify query 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.

  1. Fix broken.py so /products/1 succeeds 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.

Frequently Asked Questions

How does FastAPI read a JSON request body?
Declare a class that inherits from pydantic.BaseModel, list the expected fields with type hints, and use it as the type of a route function argument. FastAPI parses the JSON body, validates it against the model, and passes your function a model instance. Fields with a default are optional; fields without one are required.
What does response_model do in FastAPI?
response_model tells FastAPI which shape the response must have. Whatever the function returns is validated against that model and filtered down to its fields before it is sent. Returning a user object that includes a password through a response_model without a password field sends the response without the password. It also documents the response shape in /docs.
Why does FastAPI say 'Input should be a valid dictionary or object to extract fields from'?
The request body wasn't treated as JSON, usually because the client didn't send a content-type: application/json header. curl -d without -H 'content-type: application/json' sends application/x-www-form-urlencoded, and FastAPI then receives the body as a plain string instead of an object. Add the header and the same body validates.
Does FastAPI reject extra fields in a request body?
Not by default. Pydantic models ignore unknown keys, so a body with an extra 'colour' field validates and the field is simply dropped. To reject extra fields with a 422, set model_config = ConfigDict(extra='forbid') on the model.
What causes ResponseValidationError in FastAPI?
Your route returned data that doesn't fit its response_model or return type annotation, for example a dict missing a required field. This is a bug in the server, not the client's fault, so FastAPI answers 500 Internal Server Error and prints the validation error with the route's file and line in the server log. Fix the returned data or make the field optional.