Validating File Uploads and Forms in FastAPI
Key takeaways:
UploadFilespools to disk and exposesfilenameandcontent_type;bytes = File()buffers the whole upload in memory first.Formfields carry the same Pydantic constraints asQueryandBody, and their errors use thebodyprefix inloc.content_typeis client-supplied metadata, not evidence — check the bytes before you trust it.- Size limits need enforcement in the handler as you read, not just a
Content-Lengthcheck. - A JSON body parameter and a
Formfield cannot both be satisfied by one request; the endpoint 422s either way.
This page continues Request Validation Patterns into non-JSON payloads. The rules from query, path and body parameter validation still apply — including the multiple-body-parameter rule, which bites in a new way here.
Uploads require python-multipart to be installed; without it FastAPI raises at import time when it first sees a Form or File parameter.
How these transcripts were produced
The site's verification harness sends JSON bodies through tuples of (method, path, json), which cannot express a multipart request. Rather than hand-write the output — which this site never does — the example app in _verify/examples/val-uploads-forms.py drives itself: a /selftest endpoint builds genuine multipart/form-data requests with httpx and posts them back into the same app through ASGITransport, then returns each real status code and response body. The harness calls /selftest once, so every status and every 422 below travelled through a real multipart parse. The environment is FastAPI 0.139.2, Pydantic 2.13.4, python-multipart 0.0.32 and Python 3.12.
The Problem This Solves
An avatar upload endpoint works fine in development against 20 KB PNGs. In production someone posts a 400 MB video with the filename avatar.png and the Content-Type header set to image/png, and your worker's RSS goes vertical. Everything that went wrong there is a validation question: how big, what kind, and how do you find out before you have already allocated the memory.
UploadFile versus bytes
The two declarations look interchangeable and are not:
@app.post("/avatar/stream")
async def avatar_stream(file: UploadFile) -> dict[str, Any]:
# UploadFile is a spooled file: metadata is available without reading the payload.
head = await file.read(8)
await file.seek(0)
body = await file.read()
return {
"filename": file.filename,
"content_type": file.content_type,
"size": len(body),
"first_8_bytes": head.decode("latin-1"),
"type": type(file).__name__,
}
@app.post("/avatar/bytes")
async def avatar_bytes(file: Annotated[bytes, File()]) -> dict[str, Any]:
# bytes = File() buffers the WHOLE upload in memory before the handler runs.
return {"size": len(file), "type": type(file).__name__}
Real output from the self-driven multipart run:
{
"request": "POST /avatar/stream (png, 32 bytes)",
"status": 200,
"response": {
"filename": "logo.png",
"content_type": "image/png",
"size": 32,
"first_8_bytes": "\u0089PNG\r\n\u001a\n",
"type": "UploadFile"
}
},
{
"request": "POST /avatar/bytes (png, 32 bytes)",
"status": 200,
"response": {
"size": 32,
"type": "bytes"
}
},
The bytes version knows the size and nothing else — no filename, no declared content type, and no opportunity to stop reading. Starlette had already materialised the whole payload before the handler was entered. UploadFile wraps a SpooledTemporaryFile: it keeps small uploads in memory and rolls over to a temporary file past a threshold, which is why a 400 MB upload becomes disk pressure rather than an OOM kill.
UploadFile is also where filename and content_type live, and both are needed for any meaningful check. Prefer it by default. bytes = File() is reasonable only for payloads you have already bounded elsewhere — a signature blob, a small avatar behind a proxy-enforced limit.
A missing file part produces an ordinary validation error, with the body prefix because multipart is the body:
{
"request": "POST /avatar/stream (no file part at all)",
"status": 422,
"response": {
"detail": [
{
"type": "missing",
"loc": [
"body",
"file"
],
"msg": "Field required",
"input": null
}
]
}
},
Content-Type and Size Checks
Both checks belong in the handler, and both return a status other than 422, because neither is a schema violation:
MAX_BYTES = 64 # deliberately tiny so the limit is easy to trip in a transcript
ALLOWED = {"image/png", "image/jpeg"}
@app.post("/avatar/checked")
async def avatar_checked(file: UploadFile) -> dict[str, Any]:
if file.content_type not in ALLOWED:
raise HTTPException(415, f"unsupported content type {file.content_type!r}")
body = await file.read()
if len(body) > MAX_BYTES:
raise HTTPException(413, f"file exceeds {MAX_BYTES} bytes")
return {"stored": file.filename, "size": len(body)}
{
"request": "POST /avatar/checked (text/plain)",
"status": 415,
"response": {
"detail": "unsupported content type 'text/plain'"
}
},
{
"request": "POST /avatar/checked (png, 4096 bytes)",
"status": 413,
"response": {
"detail": "file exceeds 64 bytes"
}
},
415 and 413 are the right codes — the media type is unsupported, the payload is too large — and both are more actionable for a client than a generic 400. Consistent mapping of these to your error envelope is the job of a global exception handler.
Two honest caveats about the code above.
content_type is a claim, not a fact. It is whatever the client wrote in the part header. curl -F "file=@virus.exe;type=image/png" sails through. Treat it as a cheap first filter that rejects the accidental cases, then verify the bytes. The header check above is worth keeping precisely because it is cheap — it rejects most mistakes before you read anything — but it must be followed by a magic-number check on the first few bytes, and by real processing (opening the image, for instance) before you store or serve the file.
await file.read() then checking the length is too late for a big file. By the time the check runs, the bytes are already spooled. For a genuine ceiling, read in chunks and abort as you go:
async def read_bounded(file: UploadFile, limit: int) -> bytes:
chunks, total = [], 0
while chunk := await file.read(64 * 1024):
total += len(chunk)
if total > limit:
raise HTTPException(413, f"file exceeds {limit} bytes")
chunks.append(chunk)
return b"".join(chunks)
That still lets a client stream limit + 64 KB before you cut them off, which is fine. What it does not do is bound the request at the socket. Do that at the edge — client_max_body_size in nginx, --limit-request-body or its equivalent in your ASGI server — so that abusive requests never reach Python at all. The handler check is your second line of defence and the one that produces a useful error message.
Also note the Content-Length trap: a chunked request need not send one, and a client can send one that lies. It is a hint for fast rejection, never the enforcement mechanism.
Mixing Form Fields and Files
Form fields take the same constraints as Query and Body:
@app.post("/profiles/")
async def create_profile(
display_name: Annotated[str, Form(min_length=2, max_length=40)],
age: Annotated[int, Form(ge=13)],
avatar: UploadFile,
bio: Annotated[str | None, Form()] = None,
) -> dict[str, Any]:
body = await avatar.read()
return {
"display_name": display_name,
"age": age,
"bio": bio,
"avatar": {"filename": avatar.filename, "size": len(body)},
}
{
"request": "POST /profiles/ (valid form + file)",
"status": 200,
"response": {
"display_name": "Ada",
"age": 36,
"bio": "Engineer",
"avatar": {
"filename": "me.png",
"size": 20
}
}
},
{
"request": "POST /profiles/ (age=9, display_name too short, no avatar)",
"status": 422,
"response": {
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"display_name"
],
"msg": "String should have at least 2 characters",
"input": "A",
"ctx": {
"min_length": 2
}
},
{
"type": "greater_than_equal",
"loc": [
"body",
"age"
],
"msg": "Input should be greater than or equal to 13",
"input": "9",
"ctx": {
"ge": 13
}
},
{
"type": "missing",
"loc": [
"body",
"avatar"
],
"msg": "Field required",
"input": null
}
]
}
},
"input": "9" is a string, because multipart is a text protocol like the query string — every value arrives as text and is coerced. And all three failures, including the missing file, accumulate into one response, exactly as they do for JSON.
The Form-Model Trap
FastAPI 0.113 and later accept a Pydantic model bound to Form(), which is a genuinely nice way to reuse a validated shape across a form endpoint and a JSON one. It has a sharp edge:
class Metadata(BaseModel):
title: str
tags: list[str] = []
@app.post("/documents/meta-only")
async def document_meta(metadata: Annotated[Metadata, Form()]) -> dict[str, Any]:
# Its fields are read FLAT from the form, as long as it is the ONLY body parameter.
return metadata.model_dump()
@app.post("/documents/with-file")
async def document_with_file(
metadata: Annotated[Metadata, Form()],
file: UploadFile,
) -> dict[str, Any]:
# Adding a second body parameter makes FastAPI embed the model under its own name.
body = await file.read()
return {"metadata": metadata.model_dump(), "size": len(body)}
The same flat form data (title, tags) posted to both:
{
"request": "POST /documents/meta-only (form model alone, flat fields)",
"status": 200,
"response": {
"title": "Q2 report",
"tags": [
"finance",
"internal"
]
}
},
{
"request": "POST /documents/with-file (form model + file, same flat fields)",
"status": 422,
"response": {
"detail": [
{
"type": "missing",
"loc": [
"body",
"metadata"
],
"msg": "Field required",
"input": null
}
]
}
},
That is the multiple-body-parameter rule again, in its least obvious costume. The file counts as a body parameter, so the model gets embedded and FastAPI now looks for a form field literally named metadata. Browsers do not post nested form fields, so the endpoint is effectively unusable as written.
The version that works declares the fields flat:
@app.post("/documents/flat")
async def document_flat(
title: Annotated[str, Form()],
file: UploadFile,
tags: Annotated[list[str], Form()] = [],
) -> dict[str, Any]:
body = await file.read()
return {"title": title, "tags": tags, "filename": file.filename, "size": len(body)}
{
"request": "POST /documents/flat (flat Form fields + file)",
"status": 200,
"response": {
"title": "Q2 report",
"tags": [
"finance",
"internal"
],
"filename": "q2.pdf",
"size": 13
}
},
Note also that a repeated form key collects into a list[str], exactly as a repeated query key does.
Why JSON and Form Cannot Share an Endpoint
The reason is HTTP, not FastAPI: a request has one body and one Content-Type. Parsing form fields requires multipart/form-data or application/x-www-form-urlencoded; parsing a JSON body requires application/json. No single request is both.
What makes this confusing is that FastAPI does not stop you declaring it. This endpoint imports and starts cleanly:
@app.post("/mixed/")
async def mixed(metadata: Metadata, note: Annotated[str, Form()]) -> dict[str, Any]:
# A JSON body parameter alongside a Form field.
return {"metadata": metadata.model_dump(), "note": note}
Posted both ways, it fails both ways:
{
"request": "POST /mixed/ (multipart form fields)",
"status": 422,
"response": {
"detail": [
{
"type": "missing",
"loc": [
"body",
"metadata"
],
"msg": "Field required",
"input": null
}
]
}
},
{
"request": "POST /mixed/ (application/json body)",
"status": 422,
"response": {
"detail": [
{
"type": "missing",
"loc": [
"body",
"metadata"
],
"msg": "Field required",
"input": null
},
{
"type": "missing",
"loc": [
"body",
"note"
],
"msg": "Field required",
"input": null
}
]
}
}
There is no request that produces a 200. The presence of a Form parameter puts the whole endpoint into form-parsing mode, so the JSON body is never decoded — and a form post cannot supply a nested metadata object.
Two ways out. Send the JSON as a form field and parse it yourself: declare metadata: Annotated[str, Form()] and call Metadata.model_validate_json(metadata), catching ValidationError and re-raising as a 422. Browsers can do this, and it keeps one round trip. Or split the endpoint: POST /documents/ takes JSON and returns an id, then PUT /documents/{id}/content takes the multipart upload. That is more requests but a cleaner contract, and it lets the upload go direct to object storage with a pre-signed URL later without changing the metadata API.
Verification
Test uploads with TestClient, which accepts the same files= argument as httpx:
def test_rejects_oversized_upload():
client = TestClient(app)
response = client.post(
"/avatar/checked",
files={"file": ("big.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 4096, "image/png")},
)
assert response.status_code == 413
def test_form_and_file_validate_together():
client = TestClient(app)
response = client.post("/profiles/", data={"display_name": "A", "age": "9"})
locs = {tuple(e["loc"]) for e in response.json()["detail"]}
assert ("body", "avatar") in locs
In production, the signal to watch is the ratio of 413 and 415 responses to successful uploads on the same route. A sudden 413 spike usually means a client shipped a new capture resolution, not that anyone is attacking you. Wiring that up is covered in Prometheus metrics for FastAPI.
Trade-offs and When Not To
Uploading through your API at all. For anything above a few megabytes, pre-signed URLs to object storage are better: the bytes never touch your application, so upload traffic stops competing with request handling. The API then validates only the metadata and the resulting object. Accept uploads directly when you must inspect or transform the content synchronously, or when the client cannot be trusted with a storage credential.
Reading the file in the request handler. Virus scanning, transcoding and thumbnailing are not request-time work. Persist the raw bytes, return 202, and process out of band — see FastAPI BackgroundTasks vs Celery vs arq for choosing the mechanism.
Blocking file I/O in an async handler. await file.read() is fine, but a subsequent open(path, "wb").write(...) or a Pillow call is synchronous CPU and I/O on the event loop. Push it to a threadpool; the reasoning is in fixing blocking calls in async routes.
Trusting filename. It is client-controlled and may contain ../ or a null byte. Never join it onto a path. Generate your own identifier and keep the original name as metadata only.
FAQ
Should I declare an upload as UploadFile or as bytes?
Use UploadFile unless the payload is guaranteed small. UploadFile is a spooled file that stays on disk past a threshold and exposes filename and content_type, while bytes = File() reads the entire upload into memory before your handler runs, so a large upload is an allocation you cannot refuse.
Why can I not accept a JSON body and a form field in the same endpoint?
A request has one body and one Content-Type. Form parsing needs multipart/form-data or application/x-www-form-urlencoded, JSON parsing needs application/json, and no request satisfies both. FastAPI will not error at import time; the endpoint simply returns 422 for every request because one of the two parameters can never be populated.
Is the content_type on an UploadFile trustworthy?
No. It is the Content-Type the client wrote into the multipart part header, so it is entirely client-controlled. Use it as a cheap first filter, then confirm by inspecting the actual bytes with a magic-number check before storing or processing the file.
How do I enforce a maximum upload size?
Enforce it at the proxy or ASGI server for a hard ceiling, and read the file in chunks in the handler so you can abort with a 413 once the running total is exceeded. Trusting the Content-Length header alone is not enough, because a chunked request need not send one.
Why did my Pydantic form model stop working when I added a file parameter?
A Form-bound model is read flat from the form only while it is the sole body parameter. Adding an UploadFile makes it the second body parameter, so FastAPI embeds the model under its parameter name and then reports it as missing, exactly as it does with multiple JSON body parameters.
Related Reading
- Up to the guide: Request Validation Patterns for the 422 anatomy these transcripts follow.
- The JSON equivalent: Query, Path and Body Parameter Validation, where the multiple-body-parameter rule is spelled out in full.
- Sending files back: Streaming and File Responses for the outbound direction.
- Testing the multipart path: TestClient vs httpx AsyncClient for the transport used to produce this page's output.