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, Body(), a dict, a list

application/json

FastAPI parses the JSON (see Request Body & Models).

Form() parameters or a form model

application/x-www-form-urlencoded

Starlette’s form parser, which needs python-multipart.

File() or UploadFile parameters (alone or with Form())

multipart/form-data

Starlette’s multipart parser, which needs python-multipart.

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

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}
  • Form(min_length=2, max_length=40) and Form(ge=1, le=5) validate exactly as they do for query parameters.

  • Form(alias="book-id") reads the field named book-id (not a valid Python identifier) into book_id. The alias is the name clients must use, and it is also the name in the OpenAPI schema and in error loc values.

  • list[str] | None collects every occurrence of a repeated field (tags=scifi&tags=classic).

  • Without the Form() marker, a str or int parameter 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, default_factory

Default value or factory. A parameter without a default is required.

alias, validation_alias, serialization_alias, alias_priority

Alternative field names. alias is what clients send and what OpenAPI documents.

title, description

Shown in the generated schema and in Swagger UI.

gt, ge, lt, le, multiple_of, allow_inf_nan, max_digits, decimal_places

Numeric constraints.

min_length, max_length, pattern

String (and collection length) constraints. regex still exists but is deprecated: use pattern.

strict

Strict validation for this field. Because form values arrive as strings, strict=True on an int or bool form field rejects every value; leave it off.

discriminator

The field that selects the member of a tagged union.

examples, openapi_examples

Example values. example (singular) is deprecated.

deprecated, include_in_schema, json_schema_extra

OpenAPI metadata. include_in_schema=False hides the parameter from the schema.

media_type

Defaults to application/x-www-form-urlencoded for Form and multipart/form-data for File. The signature documents that changing it affects the generated OpenAPI but not how the data is parsed.

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
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, Literal and enum types, bool conversions such as spoiler=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. Sending extra=nope along 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-urlencoded request body, with additionalProperties: false when extra fields are forbidden.

  • Keep file parameters as separate File() or UploadFile parameters. A model with an UploadFile field 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")}
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 bytes, read before your function runs.

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 (spool_max_size) in memory and spills to a temporary file on disk beyond that.

Metadata

None.

filename, content_type, size, headers.

Needs File()

Yes. A bare bytes parameter is a query parameter.

No. UploadFile alone is enough; use File() only to add metadata such as description.

Async API

Not applicable.

await read(size), await write(data), await seek(offset), await close().

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

filename

The original file name as sent by the client (for example cover.png), or None if the client sent none. It is untrusted input.

content_type

The part’s Content-Type header (for example image/png). It is also declared by the client and can lie.

size

Size in bytes of the uploaded content, filled in by Starlette as the part is parsed.

headers

All headers of the part (content-disposition, content-type).

file

The underlying SpooledTemporaryFile. Pass it to libraries that expect a file-like object.

await read(size)

Reads up to size bytes (everything when omitted).

await write(data)

Writes data (bytes) to the file.

await seek(offset)

Moves to a byte position; await file.seek(0) rewinds after a first read().

await close()

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>
"""
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 sample returns {"book_id": 1, "message": "No sample sent"}; leaving out the required photos returns 422 with "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 None for an optional parameter, so if sample is None also 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}
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

-F "cover=@cover.png;type=image/gif"

415

{"detail": "Only PNG and JPEG covers are accepted"}

a .png whose content is not a PNG

415

{"detail": "File content does not match its type"}

a PNG of 3 MiB

413

{"detail": "Cover larger than 2097152 bytes"}

only -F "caption=x" (no file)

422

missing error with "loc": ["body", "cover"]

-d "caption=x" (urlencoded, no multipart)

422

missing error for cover: urlencoded bodies cannot carry files

Points to notice:

  • status_code=status.HTTP_201_CREATED is set on the decorator; see Status Codes & Error Handling.

  • The client controls filename and content_type. Never build a path from filename (../../etc/evil.png is 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}

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

max_files

1000

Maximum number of file parts in a multipart body.

max_fields

1000

Maximum number of non-file fields.

max_part_size

1 MiB

Maximum size of a non-file part (a text field). A larger one fails with 400 and Part exceeded maximum size of 1024KB. File parts are not subject to it.

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:

  1. 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 with 413 out of the box). Cloud load balancers and API gateways have their own limits and should be checked.

  2. An ASGI middleware in the app. It checks Content-Length and 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 a BaseHTTPMiddleware subclass; see Middleware, CORS & Proxies.

  3. 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}

Results of running it (limit 1 MiB):

Request Status Why

2 MiB multipart upload with a Content-Length header

413

Rejected by the declared length, before reading.

2.5 MiB sent with Transfer-Encoding: chunked (no length)

413

The limited_receive wrapper raised HTTPException while FastAPI was reading.

5-byte file

200

{"book_id": 1, "size": 5}

3-byte raw PUT consumed with request.stream()

200

{"book_id": 1, "size": 3}

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}
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: declare Query(), which is also bookmarkable and cacheable.

  • method="post" forms (creating or changing something) send a body: declare Form() (or File()).

  • The reverse also fails: curl -X POST "localhost:8000/books?title=Dune" sends a query string, so a Form() field reports missing with "loc": ["body", "title"].

  • A form that uploads files needs method="post" and enctype="multipart/form-data". Without the enctype, the browser sends the file name as a urlencoded text field and UploadFile rejects 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}
  • 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 plain def. The example was run against an in-memory S3 stand-in (the moto test library): the object arrived with the key covers/7/…​, the right size, and Content-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_storage makes the store replaceable in tests with app.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

-F "field=value" for a text part, -F "file=@path;type=image/png" for a file part, -d "field=value" for urlencoded forms. Do not set Content-Type yourself: curl adds the multipart boundary.

HTTPie

http -f POST :8000/books/7/cover cover@cover.png caption="First edition"; -f (--form) selects form encoding and field@path attaches a file.

HTTPX / TestClient

client.post(url, data={"caption": "x"}, files={"cover": ("cover.png", content, "image/png")}). data= alone sends urlencoded; adding files= switches to multipart. Repeat a field with a list of tuples: files=[("photos", (…​)), ("photos", (…​))].

Browser

<form method="post" enctype="multipart/form-data">, or fetch(url, {method: "POST", body: new FormData(form)}): let the browser set Content-Type so it includes the boundary.

Where Older Guides Are Outdated, and What Changed Recently

  • Form models. Older material declares every form field individually. Pydantic models for Form exist since 0.113.0 (2024-09-05), with extra="forbid" documented since 0.114.0.

  • Form() on GET endpoints. Some tutorials, including book chapters on HTML forms, show Form() on a GET path operation. Browsers' GET forms use the query string, so declare Query() instead (see The GET Form versus POST Form Pitfall).

  • Default-value style. Older code writes file: bytes = File() and username: str = Form(); the current style is Annotated[bytes, File()] and Annotated[str, Form()] (0.95.0 and later).

  • Pydantic v1 names. Form(regex=…​) is deprecated, use pattern=; .dict() on a form model is model_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 the strict_content_type setting does not change form handling; see Strict Content-Type Checking.

  • Missing python-multipart. Since 0.112.0 (2024-08-02) plain fastapi no longer installs it; fastapi[standard] does.

Pitfalls

  • bytes without File() is a query parameter. def upload(f: bytes) expects ?f=…​. Use Annotated[bytes, File()] or UploadFile.

  • bytes accepts a plain text field. In a test, Annotated[bytes, File()] accepted a normal form field (f=text) as if it were a file, while UploadFile rejected it with a 422. Use UploadFile when the client must send a real file.

  • Reading a large UploadFile fully. 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 filename and content_type. Both are client input. Generate file names, allow-list types and check the content.

  • Overwriting files. Two users uploading cover.png collide 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 bytes parameter. 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) or boto3 calls inside async def block the event loop. Use def, anyio or the UploadFile async 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 without enctype="multipart/form-data" uploads only the file name.

  • Setting Content-Type by hand in fetch, curl -H or HTTPX loses the multipart boundary and gives 400 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.