Uploads, file and streaming responses

FastAPI · Advanced · 14 min

Files and large or slow-to-produce responses would exhaust memory or time out if handled as one JSON blob.

Uploads arrive as multipart/form-data (parsed by python-multipart, part of fastapi[standard]). Declare file: UploadFile and you get filename, content_type, size and a spooled temporary file: kept in memory up to a threshold, then on disk, so large uploads don't eat RAM. Read it with await file.read(size) in chunks, or use the sync file.file object. A bytes parameter (with File()) loads everything into memory, so keep it for small files.

Combine files with Form() fields, and use list[UploadFile] for several files. A multipart request can't also carry a JSON body, so metadata travels as form fields. filename and content_type come from the client: never use the filename as a path, and enforce size and type limits yourself.

Sending files: FileResponse(path, media_type=..., filename="report.pdf") streams the file from disk in chunks and sets Content-Disposition so browsers download it under that name.

Streaming: StreamingResponse(iterator, media_type=...) sends whatever a sync or async generator yields as it's produced, which suits CSV exports, proxying an upstream download or LLM tokens. It bypasses response models, so you serialize each chunk yourself. Newer releases add built-ins: from 0.134 a path operation can itself yield items to stream JSON Lines, and from 0.135 response_class=EventSourceResponse (from fastapi.sse) streams Server-Sent Events.

Streaming interacts with resources: with the default dependency scope (0.118+), a database session from a yield dependency stays open until streaming finishes. That's what you want if the generator reads from it, and wasteful if it doesn't.

Example

from typing import Annotated
from fastapi import FastAPI, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient

app = FastAPI()
MAX = 1_000_000

@app.post("/upload")
async def upload(file: UploadFile, title: Annotated[str, Form()]):
    if file.content_type != "text/csv":
        raise HTTPException(415, "CSV only")
    data = await file.read(MAX + 1)
    if len(data) > MAX:
        raise HTTPException(413, "File too large")
    return {"title": title, "name": file.filename, "lines": data.count(b"\n")}

@app.get("/export.csv")
def export():
    def rows():
        yield "id,name\n"
        for i in range(3):
            yield f"{i},item-{i}\n"     # produced lazily, chunk by chunk
    return StreamingResponse(rows(), media_type="text/csv")

c = TestClient(app)
r = c.post("/upload", data={"title": "Q1"}, files={"file": ("q1.csv", b"a,b\n1,2\n", "text/csv")})
print(r.json())               # {'title': 'Q1', 'name': 'q1.csv', 'lines': 2}
print(c.get("/export.csv").text.splitlines())   # ['id,name', '0,item-0', '1,item-1', '2,item-2']

Remember

  • UploadFile spools to disk and exposes filename, content_type and size
  • Files and Form() fields go together; JSON bodies don't mix with multipart
  • FileResponse sends files from disk; StreamingResponse sends generator output
  • Validate size and type yourself; client-supplied metadata isn't trustworthy

Watch out for

  • Using the uploaded filename directly as a path on disk (path traversal).

Check yourself

  1. Why is UploadFile usually better than a bytes parameter for uploads?
  2. An endpoint needs a file plus metadata. How should the client send the metadata?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/tutorial/request-files/