Forms & File Uploads
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. FastAPI supports Python 3.10 through 3.14; every example on this page was executed on Python 3.13 with
TestClient, and the curl calls were run against a local Uvicorn server. Browsers send two kinds of bodies that are not JSON:
application/x-www-form-urlencoded (a plain HTML form) and multipart/form-data (a form with files). This page shows
how FastAPI declares, validates and documents both, how to handle uploaded files without loading them into memory,
where to limit their size, and how to store them for the Bookshelf API (book cover images and review forms).
Three Body Encodings, One Rule
A request has exactly one body, and that body has exactly one encoding, chosen by the client in the Content-Type
header. FastAPI decides how to read the body from the parameters you declare:
| Declared parameters | Body encoding expected | Who reads it |
|---|---|---|
A Pydantic model, |
|
FastAPI parses the JSON (see Request Body & Models). |
|
|
Starlette’s form parser, which needs |
|
|
Starlette’s multipart parser, which needs |
Starlette does the parsing (request.form()), FastAPI maps the parsed fields onto your parameters and runs Pydantic
validation on them, and Pydantic produces the error payloads. Form data is a flat list of name/value pairs, so a
field is always a string (or a file); FastAPI converts it to the declared type (int, bool, list[str], an enum and
so on) with Pydantic’s lax mode, the same way it converts query parameters.
Install python-multipart
Both Form and File need the python-multipart package. It is part of
fastapi[standard], so a project created as described in
Getting Started already has it. With plain fastapi add it yourself:
uv add python-multipart
# or: pip install python-multipart
Source: FastAPI: Form Data
The check happens as soon as a path operation using Form or File is declared, so a missing package fails at
import time (the RuntimeError message reads Form data requires "python-multipart" to be installed), not when the
first request arrives. A different PyPI package named multipart exists; FastAPI detects it and tells you to
uninstall it and install python-multipart instead. The name to install is always python-multipart.
Form Fields with Form()
Form() declares a parameter that is read from a form body. It works like Query() and Body(): it accepts the same
validation and metadata keywords, and it is combined with the type through Annotated.
from typing import Annotated
from fastapi import FastAPI, Form
app = FastAPI(title="Bookshelf API")
@app.post("/reviews/form")
async def create_review(
reviewer: Annotated[str, Form(min_length=2, max_length=40)],
rating: Annotated[int, Form(ge=1, le=5)],
book_id: Annotated[int, Form(alias="book-id")],
tags: Annotated[list[str] | None, Form()] = None,
text: Annotated[str | None, Form(max_length=2000)] = None,
) -> dict:
return {"reviewer": reviewer, "rating": rating, "book_id": book_id, "tags": tags, "text": text}
Source: FastAPI: Form Data
-
Form(min_length=2, max_length=40)andForm(ge=1, le=5)validate exactly as they do for query parameters. -
Form(alias="book-id")reads the field namedbook-id(not a valid Python identifier) intobook_id. The alias is the name clients must use, and it is also the name in the OpenAPI schema and in errorlocvalues. -
list[str] | Nonecollects every occurrence of a repeated field (tags=scifi&tags=classic). -
Without the
Form()marker, astrorintparameter would be a query parameter, not a form field.
Submit it with curl; -d sends application/x-www-form-urlencoded and repeats keys for lists:
curl -X POST localhost:8000/reviews/form \
-d "reviewer=Ana" -d "rating=5" -d "book-id=7" -d "tags=scifi" -d "tags=classic"
{"reviewer": "Ana", "rating": 5, "book_id": 7, "tags": ["scifi", "classic"], "text": null}
Invalid input returns 422 with one entry per problem, and loc starts with "body" followed by the field name (the
alias for aliased fields). Sending reviewer=A, rating=9 and omitting book-id produces:
{
"detail": [
{"type": "string_too_short", "loc": ["body", "reviewer"], "msg": "String should have at least 2 characters",
"input": "A", "ctx": {"min_length": 2}},
{"type": "less_than_equal", "loc": ["body", "rating"], "msg": "Input should be less than or equal to 5",
"input": "9", "ctx": {"le": 5}},
{"type": "missing", "loc": ["body", "book-id"], "msg": "Field required", "input": null}
]
}
Sending the same data as JSON instead gets every required field reported as missing: the endpoint only reads form
fields, so a JSON body is not looked at.
Options Accepted by Form() and File()
File is a class that inherits from Form, which inherits from Body, so both accept the same keyword arguments
(read from the signature in FastAPI 0.142.2):
| Parameter | Effect |
|---|---|
|
Default value or factory. A parameter without a default is required. |
|
Alternative field names. |
|
Shown in the generated schema and in Swagger UI. |
|
Numeric constraints. |
|
String (and collection length) constraints. |
|
Strict validation for this field. Because form values arrive as strings, |
|
The field that selects the member of a tagged union. |
|
Example values. |
|
OpenAPI metadata. |
|
Defaults to |
The OAuth2 Login Form
The reason the specification-defined username and password fields exist as Form fields is the OAuth2 password
flow: the token endpoint must accept form fields, not JSON. FastAPI ships OAuth2PasswordRequestForm for it, covered in
the Security section; this page only covers the general mechanism.
Form Models
Since 0.113.0 (2024-09-05) a Pydantic model can describe all the form fields at once. Declare one parameter of the
model type, marked with Form():
from typing import Annotated, Literal
from fastapi import FastAPI, Form
from pydantic import BaseModel, Field
app = FastAPI(title="Bookshelf API")
class ReviewForm(BaseModel):
model_config = {"extra": "forbid"}
reviewer: Annotated[str, Field(min_length=2, max_length=40)]
rating: Annotated[int, Field(ge=1, le=5)]
spoiler: bool = False
language: Literal["en", "es", "fr"] = "en"
tags: list[str] = Field(default_factory=list)
@app.post("/reviews/form-model")
async def create_review(review: Annotated[ReviewForm, Form()]) -> ReviewForm:
return review
Source: FastAPI: Form Models
curl -X POST localhost:8000/reviews/form-model -d "reviewer=Ana" -d "rating=5" -d "spoiler=true" -d "tags=scifi" -d "tags=classic"
{"reviewer": "Ana", "rating": 5, "spoiler": true, "language": "en", "tags": ["scifi", "classic"]}
-
The model’s fields (and
Field()constraints, defaults,Literaland enum types,boolconversions such asspoiler=true) behave as in a JSON body. -
model_config = {"extra": "forbid"}rejects fields the model does not declare. The official documentation notes this works since 0.114.0. Sendingextra=nopealong with valid fields returns:
{"detail": [{"type": "extra_forbidden", "loc": ["body", "extra"], "msg": "Extra inputs are not permitted", "input": "nope"}]}
-
Without
extra="forbid", unknown fields are ignored silently, which hides client typos. -
The OpenAPI document lists the model as the schema of an
application/x-www-form-urlencodedrequest body, withadditionalProperties: falsewhen extra fields are forbidden. -
Keep file parameters as separate
File()orUploadFileparameters. A model with anUploadFilefield was accepted in a test on 0.142.2, but the documentation describes form models for form fields only.
Form models are the form counterpart of the parameter models for query strings, headers and cookies described in
Query Parameters and Headers & Cookies.
Use a model when a form has many fields, when you want the same validation reused elsewhere, or when you need
extra="forbid". Use individual Form() parameters for two or three fields.
Files: bytes versus UploadFile
Files are sent as parts of a multipart/form-data body. Declare them with File() (as bytes) or with the
UploadFile class:
from typing import Annotated
from fastapi import FastAPI, File, UploadFile
app = FastAPI(title="Bookshelf API")
@app.post("/covers/bytes")
async def cover_as_bytes(cover: Annotated[bytes, File(description="Cover image read fully into memory")]) -> dict:
return {"size": len(cover)}
@app.post("/covers/upload")
async def cover_as_upload_file(
cover: Annotated[UploadFile, File(description="Cover image as a spooled file")],
) -> dict:
head = await cover.read(4) # first 4 bytes
await cover.seek(0) # rewind
whole = await cover.read()
return {
"filename": cover.filename,
"content_type": cover.content_type,
"size": cover.size,
"headers": dict(cover.headers),
"first_bytes": head.hex(),
"bytes_read": len(whole),
}
@app.post("/covers/sync")
def cover_in_a_sync_function(cover: UploadFile) -> dict:
# In a plain `def` function the underlying SpooledTemporaryFile can be used directly.
return {"first_line": cover.file.readline().decode(errors="replace")}
Source: FastAPI: Request Files
curl -X POST localhost:8000/covers/upload -F "cover=@cover.png;type=image/png"
{
"filename": "cover.png",
"content_type": "image/png",
"size": 108,
"headers": {
"content-disposition": "form-data; name=\"cover\"; filename=\"cover.png\"",
"content-type": "image/png"
},
"first_bytes": "89504e47",
"bytes_read": 108
}
Annotated[bytes, File()] |
UploadFile |
|
|---|---|---|
What you receive |
The whole content as |
A file-like object that wraps a spooled temporary file. |
Memory |
The entire file is held in memory. Fine for small files only. |
Starlette keeps up to 1 MiB ( |
Metadata |
None. |
|
Needs |
Yes. A bare |
No. |
Async API |
Not applicable. |
|
Typical use |
Tiny files (an avatar of a few KB, a small JSON or CSV). |
Everything else: images, PDFs, videos, anything of unknown size. |
UploadFile Attributes and Methods
| Member | Meaning |
|---|---|
|
The original file name as sent by the client (for example |
|
The part’s |
|
Size in bytes of the uploaded content, filled in by Starlette as the part is parsed. |
|
All headers of the part ( |
|
The underlying |
|
Reads up to |
|
Writes |
|
Moves to a byte position; |
|
Closes the file. FastAPI closes uploaded files when the request ends, so you rarely need to. |
The async methods run the blocking file operations in a threadpool, so use await in an async def path operation. In a
plain def path operation (which FastAPI already runs in a threadpool, see
Async & Concurrency) use cover.file directly, as the /covers/sync
function above does. The UploadFile class that FastAPI exports inherits from Starlette’s, with changes that make
it work with Pydantic.
Effect on OpenAPI
A path operation with a File or UploadFile parameter gets a multipart/form-data request body and Swagger UI
shows a file chooser. In the schema, each file is {"type": "string", "contentMediaType": "application/octet-stream"}
(the OpenAPI 3.1 notation, replacing the older format: binary); File(description=…) fills description. A Form()
only operation documents application/x-www-form-urlencoded. See
OpenAPI & Interactive Docs.
Optional and Multiple Files
Make a file optional with | None and a default of None. Accept several files sent under one field name with
list[UploadFile] or Annotated[list[bytes], File()]:
from typing import Annotated
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse
app = FastAPI(title="Bookshelf API")
@app.post("/books/{book_id}/sample")
async def optional_sample(book_id: int, sample: UploadFile | None = None) -> dict:
if sample is None:
return {"book_id": book_id, "message": "No sample sent"}
return {"book_id": book_id, "filename": sample.filename}
@app.post("/books/{book_id}/photos")
async def many_photos(book_id: int, photos: list[UploadFile]) -> dict:
return {"book_id": book_id, "filenames": [p.filename for p in photos]}
@app.post("/books/{book_id}/excerpts")
async def many_small_files(
book_id: int,
excerpts: Annotated[list[bytes], File(description="Short text excerpts")],
) -> dict:
return {"book_id": book_id, "sizes": [len(e) for e in excerpts]}
@app.get("/upload-demo", response_class=HTMLResponse)
async def upload_demo() -> str:
return """
<form action="/books/1/photos" enctype="multipart/form-data" method="post">
<input name="photos" type="file" multiple accept="image/*">
<input type="submit" value="Upload">
</form>
"""
Source: FastAPI: Request Files
curl -X POST localhost:8000/books/1/photos -F "photos=@a.jpg" -F "photos=@b.jpg"
{"book_id": 1, "filenames": ["a.jpg", "b.jpg"]}
-
Repeat the same field name once per file (
-F "photos=@a.jpg" -F "photos=@b.jpg"); with an HTML<input type="file" multiple>the browser does this. -
Leaving out the optional
samplereturns{"book_id": 1, "message": "No sample sent"}; leaving out the requiredphotosreturns422with"type": "missing"and"loc": ["body", "photos"]. -
A browser submits an empty file input as a part with an empty file name and no content. In a test, FastAPI turned that part into
Nonefor an optional parameter, soif sample is Nonealso covers the "user did not choose a file" case.
Forms and Files in One Request
Form() and File() parameters can be mixed in one path operation; the body is then multipart/form-data, in which
text fields are parts without a file name. This is the cover upload of the Bookshelf API. It also validates the file,
stores it under a generated name and enforces a size limit while copying:
import os
import uuid
from pathlib import Path
from typing import Annotated
import anyio
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
UPLOAD_DIR = Path(os.environ.get("BOOKSHELF_UPLOAD_DIR", "uploads"))
MAX_COVER_BYTES = 2 * 1024 * 1024
CHUNK_SIZE = 64 * 1024
ALLOWED = { # content type -> (extension, magic number)
"image/png": (".png", b"\x89PNG\r\n\x1a\n"),
"image/jpeg": (".jpg", b"\xff\xd8\xff"),
}
app = FastAPI(title="Bookshelf API")
@app.post("/books/{book_id}/cover", status_code=status.HTTP_201_CREATED)
async def upload_cover(
book_id: int,
cover: Annotated[UploadFile, File(description="PNG or JPEG, at most 2 MiB")],
caption: Annotated[str | None, Form(max_length=200)] = None,
) -> dict:
# 1. The declared type is only a hint from the client, so allow-list it AND check the real bytes.
if cover.content_type not in ALLOWED:
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Only PNG and JPEG covers are accepted")
extension, magic = ALLOWED[cover.content_type]
if not (await cover.read(len(magic))).startswith(magic):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "File content does not match its type")
await cover.seek(0)
# 2. Never use the client's file name for the path: generate one.
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
stored_name = f"{book_id}-{uuid.uuid4().hex}{extension}"
target = UPLOAD_DIR / stored_name
# 3. Copy in chunks and stop as soon as the limit is exceeded.
written = 0
async with await anyio.open_file(target, "wb") as out:
while chunk := await cover.read(CHUNK_SIZE):
written += len(chunk)
if written > MAX_COVER_BYTES:
break
await out.write(chunk)
if written > MAX_COVER_BYTES:
target.unlink(missing_ok=True)
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, f"Cover larger than {MAX_COVER_BYTES} bytes")
return {"book_id": book_id, "stored_as": stored_name, "size": written, "caption": caption}
Source: FastAPI: Request Forms and Files
curl -i -F "cover=@cover.png;type=image/png" -F "caption=First edition" localhost:8000/books/7/cover
{"book_id": 7, "stored_as": "7-2cb2289a42dc40dea722678581b8e269.png", "size": 12, "caption": "First edition"}
The same endpoint answers invalid requests like this (all run against the server):
| Request | Status | Body |
|---|---|---|
|
|
|
a |
|
|
a PNG of 3 MiB |
|
|
only |
|
|
|
|
|
Points to notice:
-
status_code=status.HTTP_201_CREATEDis set on the decorator; see Status Codes & Error Handling. -
The client controls
filenameandcontent_type. Never build a path fromfilename(../../etc/evil.pngis a valid value); generate the name, as the example does, and check the real bytes (the magic number at the start of the file) in addition to the declared type. -
The size check happens after FastAPI has already received the whole body and spooled it to disk, so it protects the storage but not the server’s bandwidth. See Size Limits and Where to Enforce Them for limits that act earlier.
Why a Body Cannot Be JSON and Form at Once
Content-Type is one value per request, so a body is either JSON, or urlencoded, or multipart. The official
documentation states it as a limit of HTTP rather than of FastAPI. If you declare a Pydantic model body next to a
Form() parameter, FastAPI does not combine them: every body parameter is read from the form, and the model is no longer
parsed from JSON.
from typing import Annotated
from fastapi import FastAPI, File, Form, UploadFile
from pydantic import BaseModel, Json
app = FastAPI(title="Bookshelf API")
class BookMeta(BaseModel):
title: str
isbn: str | None = None
# Broken on purpose: `meta` is a JSON body field, `token` is a form field.
@app.post("/books/broken")
async def create_book_broken(meta: BookMeta, token: Annotated[str, Form()]) -> dict:
return {"title": meta.title}
# Working: the JSON travels as a string inside one multipart field and Pydantic parses it.
@app.post("/books/with-cover")
async def create_book(
meta: Annotated[Json[BookMeta], Form(description="JSON document")],
cover: UploadFile,
) -> dict:
return {"title": meta.title, "isbn": meta.isbn, "cover": cover.filename}
Source: FastAPI: Request Forms and Files
Sending JSON to /books/broken reports both meta and token as missing (the JSON body is not read), and sending a form
with a JSON string in meta fails with "type": "model_attributes_type" because a form field is a string, not an
object. The OpenAPI document of that operation lists application/x-www-form-urlencoded, not JSON.
The working pattern is to send the JSON document as a string field of the form and let Pydantic parse it with
Json[Model]:
curl -X POST localhost:8000/books/with-cover \
-F 'meta={"title": "Dune", "isbn": "9780441172719"}' -F "cover=@cover.png;type=image/png"
{"title": "Dune", "isbn": "9780441172719", "cover": "cover.png"}
Malformed JSON in that field returns 422 with "type": "json_invalid" and "loc": ["body", "meta"]. The field appears
in OpenAPI as {"type": "string", "contentMediaType": "application/json", "contentSchema": {"$ref": …}}. The alternatives are
two requests (create the book as JSON, then upload the cover to /books/{id}/cover), which is simpler to validate and
retry, or a direct-to-storage upload (see To an Object Store).
Size Limits and Where to Enforce Them
FastAPI has no setting for the maximum request or file size, and nothing limits a file part: the 3 MiB file in the cover
example was accepted without complaint. What exists comes from Starlette’s form parser, which FastAPI calls with its
defaults for parameters declared with Form or File (checked in the FastAPI 0.142.2 source):
| Limit | Default | Behaviour |
|---|---|---|
|
1000 |
Maximum number of file parts in a multipart body. |
|
1000 |
Maximum number of non-file fields. |
|
1 MiB |
Maximum size of a non-file part (a text field). A larger one fails with |
To change them, read the form yourself with await request.form(max_files=…, max_fields=…, max_part_size=…); see
Reading the Body for the Request object.
An overall size limit must be enforced in layers, from the cheapest to the most precise:
-
The reverse proxy or load balancer. The first and best place, because a rejected upload never reaches Python. For nginx the directive is
client_max_body_size(it defaults to 1 MB, so uploads fail with413out of the box). Cloud load balancers and API gateways have their own limits and should be checked. -
An ASGI middleware in the app. It checks
Content-Lengthand counts the bytes as they stream in, so it also stops chunked uploads that declare no length. Because it runs before FastAPI reads the body, nothing has been spooled yet. Write it as a pure ASGI middleware, not aBaseHTTPMiddlewaresubclass; see Middleware, CORS & Proxies. -
The endpoint. Count bytes while copying, as the cover example does, to enforce per-endpoint limits (a cover may be 2 MiB, a book PDF 50 MiB).
A route-level dependency is not an early check. FastAPI parses the form before it solves dependencies, so a
Depends that inspects Content-Length runs only after the upload has been received and spooled. A body larger
than the non-file part limit even fails with 400 before the dependency runs. Use a middleware or the proxy for the early checks.
from fastapi import FastAPI, HTTPException, Request, UploadFile, status
from fastapi.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
MAX_BODY = 1 * 1024 * 1024 # 1 MiB
class LimitRequestBodySize:
"""Pure ASGI middleware: reject oversized bodies before FastAPI parses them."""
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
declared = dict(scope["headers"]).get(b"content-length")
if declared is not None and declared.isdigit() and int(declared) > self.max_bytes:
response = JSONResponse({"detail": "Request body too large"}, status.HTTP_413_CONTENT_TOO_LARGE)
await response(scope, receive, send) # rejected without reading the body
return
received = 0
async def limited_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_bytes: # chunked uploads have no Content-Length
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Request body too large")
return message
await self.app(scope, limited_receive, send)
app = FastAPI(title="Bookshelf API")
app.add_middleware(LimitRequestBodySize, max_bytes=MAX_BODY)
@app.post("/books/{book_id}/cover-form")
async def upload_cover_form(book_id: int, cover: UploadFile) -> dict:
return {"book_id": book_id, "size": cover.size}
@app.put("/books/{book_id}/cover-raw")
async def upload_cover_raw(book_id: int, request: Request) -> dict:
"""Raw upload (Content-Type: image/png, body = the file), consumed chunk by chunk."""
received = 0
async for chunk in request.stream():
received += len(chunk)
# ... write the chunk to disk or to an object store here ...
return {"book_id": book_id, "size": received}
Source: Starlette: Middleware
Results of running it (limit 1 MiB):
| Request | Status | Why |
|---|---|---|
2 MiB multipart upload with a |
|
Rejected by the declared length, before reading. |
2.5 MiB sent with |
|
The |
5-byte file |
|
|
3-byte raw |
|
|
The middleware raises an HTTPException from inside receive; FastAPI re-raises exceptions of that type while reading
the body, so the client sees a clean 413. Any other exception type raised there would be turned into
400 There was an error parsing the body. Request.stream() (see
The Request Stream) is the right tool for a raw upload, where the
body is the file itself (PUT with Content-Type: image/png), because it never builds a form: each chunk can be forwarded to disk or to an object store as it arrives.
The GET Form versus POST Form Pitfall
An HTML <form> with method="get" does not send a body: the browser appends the fields to the URL as a query string.
Form() reads the body. Declaring Form() on a GET operation therefore never sees a browser’s GET form. FastAPI does
not stop you: the operation is created, the OpenAPI document even lists a request body on the GET, and every call
fails with 422.
from typing import Annotated
from fastapi import FastAPI, Form, Query
from fastapi.responses import HTMLResponse
app = FastAPI(title="Bookshelf API")
@app.get("/search-page", response_class=HTMLResponse)
async def search_page() -> str:
return """
<form action="/search" method="get"> <!-- fields go in the query string -->
<input name="q"> <input type="submit" value="Search">
</form>
<form action="/books" method="post"> <!-- fields go in the body (urlencoded) -->
<input name="title"> <input type="submit" value="Create">
</form>
"""
# Wrong: Form() on a GET operation reads the BODY, but a browser's GET form puts its fields in the URL.
@app.get("/search-wrong")
async def search_wrong(q: Annotated[str, Form()]) -> dict:
return {"q": q}
# Right for GET: query parameters.
@app.get("/search")
async def search(q: Annotated[str, Query(min_length=1)]) -> dict:
return {"q": q}
# Right for POST: form fields.
@app.post("/books")
async def create_book(title: Annotated[str, Form(min_length=1)]) -> dict:
return {"title": title}
Source: FastAPI: Form Data
curl "localhost:8000/search-wrong?q=dune"
{"detail": [{"type": "missing", "loc": ["body", "q"], "msg": "Field required", "input": null}]}
The rule:
-
method="get"forms (searches, filters) send query parameters: declareQuery(), which is also bookmarkable and cacheable. -
method="post"forms (creating or changing something) send a body: declareForm()(orFile()). -
The reverse also fails:
curl -X POST "localhost:8000/books?title=Dune"sends a query string, so aForm()field reportsmissingwith"loc": ["body", "title"]. -
A form that uploads files needs
method="post"andenctype="multipart/form-data". Without theenctype, the browser sends the file name as a urlencoded text field andUploadFilerejects it with"msg": "Value error, Expected UploadFile, received: <class 'str'>".
Saving Uploads
To Local Disk
The cover example already saves to disk: it opens a file with anyio.open_file (non-blocking) and copies
await cover.read(CHUNK_SIZE) chunks. In a plain def endpoint you can use shutil.copyfileobj(cover.file, out)
instead, which FastAPI runs in the threadpool. Remember that a container’s disk is usually ephemeral and not shared between
replicas: local disk is fine for development and for a single-server deployment with a mounted volume, not for several workers on several machines.
To an Object Store
For anything running on more than one instance, store the file in an object store and keep only its key in the database. The page on S3 Object Storage describes buckets, keys, multipart upload and presigned URLs, and Azure Blob Storage does the same for containers, blob types, SAS tokens and the Python SDK. Hide the store behind a small interface so the endpoint does not care which one it is, and so tests can use the local implementation:
import os
import shutil
import uuid
from pathlib import Path
from typing import Annotated, BinaryIO, Protocol
import boto3
from fastapi import Depends, FastAPI, UploadFile, status
UPLOAD_DIR = Path(os.environ.get("BOOKSHELF_UPLOAD_DIR", "uploads"))
BUCKET = os.environ.get("BOOKSHELF_COVER_BUCKET", "bookshelf-covers")
class CoverStorage(Protocol):
def save(self, key: str, fileobj: BinaryIO, content_type: str | None) -> str: ...
class LocalStorage:
def save(self, key: str, fileobj: BinaryIO, content_type: str | None) -> str:
target = UPLOAD_DIR / key
target.parent.mkdir(parents=True, exist_ok=True)
with open(target, "wb") as out:
shutil.copyfileobj(fileobj, out)
return target.as_uri()
class S3Storage:
def __init__(self) -> None:
self.client = boto3.client("s3") # credentials come from the environment / IAM role
def save(self, key: str, fileobj: BinaryIO, content_type: str | None) -> str:
extra = {"ContentType": content_type} if content_type else {}
# upload_fileobj streams the file-like object and uses multipart upload for big files
self.client.upload_fileobj(fileobj, BUCKET, key, ExtraArgs=extra)
return f"s3://{BUCKET}/{key}"
def get_storage() -> CoverStorage:
return S3Storage() if os.environ.get("BOOKSHELF_STORAGE") == "s3" else LocalStorage()
Storage = Annotated[CoverStorage, Depends(get_storage)]
app = FastAPI(title="Bookshelf API")
# A plain `def` endpoint runs in the threadpool, so the blocking copy/upload does not block the event loop.
@app.post("/books/{book_id}/cover", status_code=status.HTTP_201_CREATED)
def upload_cover(book_id: int, cover: UploadFile, storage: Storage) -> dict:
key = f"covers/{book_id}/{uuid.uuid4().hex}"
location = storage.save(key, cover.file, cover.content_type)
return {"book_id": book_id, "location": location}
Source: Boto3: S3 upload_fileobj
-
upload_fileobj(cover.file, …)streams the spooled file to S3 and uses multipart upload for large files, so the whole file is never loaded into memory. It is blocking, so the endpoint is a plaindef. The example was run against an in-memory S3 stand-in (themototest library): the object arrived with the keycovers/7/…, the right size, andContent-Type: image/png. -
Credentials come from the environment or the instance or pod role, never from code.
-
Azure Blob Storage works the same way: upload from
cover.file(a stream), authenticate with Microsoft Entra ID rather than an account key as the Blob page recommends, and store the blob name. -
The dependency
get_storagemakes the store replaceable in tests withapp.dependency_overrides; see Dependency Injection and Testing.
Directly from the Client
For large files the cheapest design is to keep the bytes out of your API altogether: the API authenticates the user and returns a short-lived signed upload permission, and the client uploads straight to the bucket. S3 calls it a presigned POST (or presigned PUT URL); Azure Blob Storage uses a SAS token.
import os
import uuid
import boto3
from fastapi import FastAPI
BUCKET = os.environ.get("BOOKSHELF_COVER_BUCKET", "bookshelf-covers")
app = FastAPI(title="Bookshelf API")
@app.post("/books/{book_id}/cover-upload-url")
def create_cover_upload_url(book_id: int) -> dict:
"""Let the client upload straight to the bucket; the API never sees the bytes."""
key = f"covers/{book_id}/{uuid.uuid4().hex}"
s3 = boto3.client("s3")
post = s3.generate_presigned_post(
BUCKET,
key,
Conditions=[["content-length-range", 1, 2 * 1024 * 1024]], # enforced by S3
ExpiresIn=300,
)
return {"key": key, **post}
The response holds the bucket url and the form fields (including key, policy and a signature) that the client must send as
multipart/form-data together with the file. The content-length-range condition is checked by S3 itself, so the size limit is enforced by the storage service and not
by your code. The upload does not pass through FastAPI, so your API needs to be told about the finished upload (an event notification or a follow-up call) before it marks the cover as present.
Serving the Files Back
Stored files are returned with FileResponse or StreamingResponse (see
Custom Responses) or, for objects in a bucket, by redirecting to a signed
download URL. Always serve uploads with the content type you determined, not the one the client declared, and never serve
user uploads from the same origin as the application if they could be HTML or SVG.
Calling Upload Endpoints
| Client | Syntax |
|---|---|
curl |
|
HTTPie |
|
HTTPX / |
|
Browser |
|
Where Older Guides Are Outdated, and What Changed Recently
-
Form models. Older material declares every form field individually. Pydantic models for
Formexist since 0.113.0 (2024-09-05), withextra="forbid"documented since 0.114.0. -
Form()on GET endpoints. Some tutorials, including book chapters on HTML forms, showForm()on aGETpath operation. Browsers' GET forms use the query string, so declareQuery()instead (see The GET Form versus POST Form Pitfall). -
Default-value style. Older code writes
file: bytes = File()andusername: str = Form(); the current style isAnnotated[bytes, File()]andAnnotated[str, Form()](0.95.0 and later). -
Pydantic v1 names.
Form(regex=…)is deprecated, usepattern=;.dict()on a form model ismodel_dump(). -
Strict
Content-Type(0.132.0, 2026-02-23). The check applies to JSON bodies only. Form and file parameters are parsed from their own content types, so thestrict_content_typesetting does not change form handling; see Strict Content-Type Checking. -
Missing
python-multipart. Since 0.112.0 (2024-08-02) plainfastapino longer installs it;fastapi[standard]does.
Pitfalls
-
byteswithoutFile()is a query parameter.def upload(f: bytes)expects?f=…. UseAnnotated[bytes, File()]orUploadFile. -
bytesaccepts a plain text field. In a test,Annotated[bytes, File()]accepted a normal form field (f=text) as if it were a file, whileUploadFilerejected it with a422. UseUploadFilewhen the client must send a real file. -
Reading a large
UploadFilefully.await cover.read()without a size loads everything into memory, defeating the spooled file. Read in chunks. -
Reading twice. After one
read()the position is at the end;await cover.seek(0)before reading again. -
Trusting
filenameandcontent_type. Both are client input. Generate file names, allow-list types and check the content. -
Overwriting files. Two users uploading
cover.pngcollide if you use the original name. -
Limits that never trigger. No limit is on by default; an unbounded upload can fill the disk (the spool and the final copy) and the memory of a
bytesparameter. Set a limit in the proxy and in the app. -
Late dependency checks. A dependency does not run before the body is parsed.
-
Blocking in
async def.shutil.copyfileobj(cover.file, out)orboto3calls insideasync defblock the event loop. Usedef,anyioor theUploadFileasync methods. -
JSON next to a form. A body cannot be both; use
Json[Model]in a form field or two requests (Why a Body Cannot Be JSON and Form at Once). -
Missing
enctype. A file input in a form withoutenctype="multipart/form-data"uploads only the file name. -
Setting
Content-Typeby hand infetch,curl -Hor HTTPX loses the multipart boundary and gives400 Missing boundary in multipart.
For processing uploaded images and videos (Pillow, pyvips, FFmpeg) and why video needs a real queue rather than BackgroundTasks, see Python Image and Video Libraries.