Request Body + Path/Query Params

Difficulty: Intermediate

In real APIs, you often need to accept data from multiple sources in a single request: path parameters for resource identification, query parameters for options, and a request body for the main data payload. FastAPI handles this combination seamlessly by automatically determining where each parameter comes from based on its type and declaration.

The rule is simple: if a parameter is declared in the path, it is a path parameter. If it is a Pydantic model or annotated with Body(), it comes from the request body. Everything else is a query parameter. This means you can freely mix all three in a single endpoint function.

When you have multiple Pydantic model parameters, FastAPI expects the request body to be a JSON object where each key corresponds to a model parameter name. For example, if you have def create(item: Item, user: User), FastAPI expects {"item": {...}, "user": {...}} in the request body.

The Body() function is used for singular values in the request body (not wrapped in a model). For example, importance: int = Body() tells FastAPI to read 'importance' from the JSON body rather than treating it as a query parameter. You can also use Body(embed=True) with a single model to wrap it in a key.

FastAPI also supports form data through the Form() function, which reads data from application/x-www-form-urlencoded format (the format used by HTML forms). File uploads use the File() and UploadFile types. These cannot be mixed with JSON body parameters in the same endpoint.

A common pattern is using path parameters for the resource ID, query parameters for options like 'fields' or 'expand', and the request body for the actual data. For example, PUT /users/{user_id}?notify=true with a JSON body containing the updated user data. This keeps URLs meaningful while providing rich data submission.

Code examples

Body + Path + Query Parameters

from fastapi import FastAPI, Query
from pydantic import BaseModel

app = FastAPI()

class ItemUpdate(BaseModel):
    name: str
    price: float
    description: str | None = None

@app.put("/items/{item_id}")
def update_item(
    item_id: int,                      # Path parameter
    item: ItemUpdate,                   # Request body
    notify: bool = False,              # Query parameter
    fields: str | None = None,         # Query parameter
):
    result = {"item_id": item_id, item.model_dump()}
    if notify:
        result["notification"] = "Item updated notification sent"
    return result

# PUT /items/42?notify=true
# Body: {"name": "Widget Pro", "price": 19.99}
# -> {"item_id": 42, "name": "Widget Pro", "price": 19.99, ...}

FastAPI automatically determines each parameter's source: item_id is in the path, item is a Pydantic model (so it comes from the body), and notify/fields are plain types without path or body declarations (so they are query parameters).

Multiple Body Parameters

from fastapi import FastAPI, Body
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

class User(BaseModel):
    username: str
    email: str

@app.post("/purchases")
def create_purchase(
    item: Item,
    user: User,
    note: str = Body(default=""),
):
    return {
        "item": item.model_dump(),
        "user": user.model_dump(),
        "note": note,
    }

# Expected request body:
# {
#   "item": {"name": "Widget", "price": 9.99},
#   "user": {"username": "alice", "email": "alice@example.com"},
#   "note": "Gift wrapping please"
# }

# With a single model and embed=True:
@app.post("/items")
def create_item(item: Item = Body(embed=True)):
    return item

# Expected body: {"item": {"name": "Widget", "price": 9.99}}
# Without embed: {"name": "Widget", "price": 9.99}

Multiple body parameters create a nested JSON structure keyed by parameter name. Body() declares singular values that come from the request body. embed=True wraps a single model in a key, changing the expected format.

Form Data and File Uploads

from fastapi import FastAPI, Form, File, UploadFile

app = FastAPI()

# Form data (application/x-www-form-urlencoded)
@app.post("/login")
def login(
    username: str = Form(),
    password: str = Form(),
):
    return {"username": username}

# File upload
@app.post("/upload")
async def upload_file(
    file: UploadFile,
    description: str = Form(default=""),
):
    contents = await file.read()
    return {
        "filename": file.filename,
        "size": len(contents),
        "content_type": file.content_type,
        "description": description,
    }

# Multiple file upload
@app.post("/upload-multiple")
async def upload_multiple(files: list[UploadFile]):
    return [
        {"filename": f.filename, "size": f.size}
        for f in files
    ]

Form() reads from URL-encoded form data. UploadFile handles file uploads with streaming support. You can combine Form() with UploadFile in the same endpoint, but you cannot mix Form/File with JSON Body parameters.

Key points

Concepts covered

Body + Path, Body + Query, Multiple Bodies, Body Embed, Form Data