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).