Request bodies with Pydantic, and file uploads

A BaseModel parameter is the request body

A GET request’s only data is its URI. POST/PUT/PATCH can also send a request body — a separate chunk of data, sent alongside the request instead of packed into the URI. The curl flags below each do one job:

  • -X POST — the HTTP method (curl defaults to GET)
  • -H "Content-Type: application/json" — a header, telling the server the body that follows is JSON
  • -d '...' — the request body itself, attached as literal text
  • -v — verbose: prints the raw request and response, not just the final result, which is what makes the headers/body split visible below
The request body, made visible
click Run to see this pane's output

Everything after the blank line following the > headers is the body — plain JSON text, exactly the same format from the I/O lesson, just sent over the network instead of read from a file. Content-Type: application/json is what tells the server to interpret it that way.

This is where Lesson 5’s Pydantic payoff actually lands: a function parameter typed as a BaseModel tells FastAPI to parse and validate that JSON body against the model, automatically, before the route function runs at all.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class AgentConfig(BaseModel):
    name: str
    model: str
    temperature: float = 0.7

@app.post("/agents")
def create_agent(config: AgentConfig):
    return {"created": config.name, "model": config.model}

The same request from the demo above calls create_agent(config=AgentConfig(name="research_agent", model="claude-sonnet"))config arrives already validated, already a real AgentConfig instance with temperature defaulted to 0.7, exactly as AgentConfig(**raw) worked back in the Pydantic lesson, just triggered by an incoming HTTP request instead of a manually-constructed dict. A malformed body — a missing name, a temperature that can’t be coerced to a float — produces the same structured 422 response covered in the previous concept, automatically, before create_agent ever runs.

Combining a body with path and query parameters

FastAPI figures out where each parameter comes from by its type and whether it matches a path segment — a BaseModel parameter is understood as the body, a plain type matching {"{...}"} in the path is a path parameter, and anything else falls back to a query parameter, all in the same function signature:

@app.put("/agents/{agent_id}")
def update_agent(agent_id: int, config: AgentConfig, notify: bool = False):
    return {"agent_id": agent_id, "updated": config.name, "notify": notify}
Path, body, and query — all in one request
click Run to see this pane's output

That single request populates all three parameters from their respective sources — agent_id from the URI path, config from the JSON body, notify from the query string — without needing to say so explicitly anywhere; FastAPI infers each parameter’s source from its type and its name.

File uploads

A file in a request is a different kind of data than JSON — UploadFile (paired with File()) is FastAPI’s way of receiving one, relevant here specifically for a case like a document an agent needs to process:

from fastapi import FastAPI, File, UploadFile

app = FastAPI()

@app.post("/documents")
async def upload_document(file: UploadFile = File(...)):
    contents = await file.read()
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size_bytes": len(contents),
    }
Uploading a file
click Run to see this pane's output

File(...) (the ... meaning “required, no default”) marks file as an uploaded file rather than a JSON body field. UploadFile gives you .filename, .content_type, and — since reading a file’s contents is I/O — an async .read() method, exactly the await-a-coroutine pattern from the async lesson. This is also why upload_document is declared async def here: reading an uploaded file’s bytes is genuinely I/O-bound work, the same category Lesson 7 built concurrency around.

Check your understanding
1/4

What happens when a function parameter is typed as a Pydantic BaseModel in a route function?