A practical guide to uploading single/multiple files, validating size and type, and streaming to disk or cloud — without slowing down your API.
Learn how to handle file uploads in FastAPI: single & multiple files, validation, streaming saves, background tasks, and cloud-friendly patterns with code.
Uploading files sounds simple — until someone sends a 600 MB video with the wrong MIME type at 5 p.m. Your API needs to be fast, careful, and hard to break. Let's make uploads boringly reliable.
What "good" looks like
Before we write code, align on outcomes you actually want in production:
- Fast requests: non-blocking I/O and streaming writes
- Safety: strict size limits and content-type checks
- Flexibility: support single and multiple files, plus extra form fields
- Portability: save to disk today, switch to cloud tomorrow
- Observability: clear errors and metrics you can trust
We'll build a thin upload layer that checks all five.
The basic building blocks
FastAPI gives you two key primitives:
UploadFile
— an efficient wrapper overSpooledTemporaryFile
, with async.read()
and file-like.file
File()
— tells FastAPI to expect multipart/form-data rather than JSON
Single file upload
# main.py
from fastapi import FastAPI, File, UploadFile, HTTPException
app = FastAPI()
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
if file.content_type not in {"image/png", "image/jpeg", "application/pdf"}:
raise HTTPException(415, "Unsupported file type")
data = await file.read() # OK for small files
# ... process or store data ...
return {"filename": file.filename, "type": file.content_type, "bytes": len(data)}
When to use this: small files (e.g., avatars, PDFs under a few MB). For larger files, stream to disk instead of reading into memory.
Multiple files + form fields
Uploads rarely travel alone; you might also get user_id
, description
, or flags.
from typing import List, Optional
from fastapi import Form
@app.post("/upload/many")
async def upload_many(
files: List[UploadFile] = File(...),
user_id: str = Form(...),
note: Optional[str] = Form(None),
):
total = 0
for f in files:
if f.content_type not in {"image/png", "image/jpeg"}:
raise HTTPException(415, f"Bad type for {f.filename}")
size = len(await f.read())
total += size
return {"count": len(files), "bytes": total, "user_id": user_id, "note": note}
Stream to disk (or anywhere) without blowing memory
Reading the whole file is easy — but wasteful for large uploads. Stream in chunks.
import aiofiles
from pathlib import Path
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
@app.post("/upload/stream")
async def upload_stream(file: UploadFile = File(...)):
target = UPLOAD_DIR / file.filename
# 1. validate content-type early
if file.content_type not in {"video/mp4", "application/pdf", "image/png", "image/jpeg"}:
raise HTTPException(415, "Unsupported file type")
# 2. stream to disk in chunks
async with aiofiles.open(target, "wb") as out:
while chunk := await file.read(1024 * 1024): # 1 MB
await out.write(chunk)
return {"stored_as": str(target)}
This pattern is provider-agnostic: replace the aiofiles
block with a cloud client's async uploader if you need S3, GCS, or similar.
Background processing: respond now, work later
Let's be real: thumbnails, virus scans, and OCR shouldn't block a response.
from fastapi import BackgroundTasks
async def generate_thumbnail(path: str) -> None:
# heavy work here (or dispatch to Celery/RQ)
...
@app.post("/upload/with-bg")
async def upload_with_bg(background: BackgroundTasks, file: UploadFile = File(...)):
target = UPLOAD_DIR / file.filename
async with aiofiles.open(target, "wb") as out:
while chunk := await file.read(1_000_000):
await out.write(chunk)
background.add_task(generate_thumbnail, str(target))
return {"status": "queued", "file": file.filename}
Enforce size limits (so you don't become a free storage service)
You can gate uploads at multiple layers. A simple approach is to reject requests whose Content-Length
exceeds your limit.
from fastapi import Request
MAX_MB = 25
MAX_BYTES = MAX_MB * 1024 * 1024
@app.post("/upload/limited")
async def upload_limited(request: Request, file: UploadFile = File(...)):
length = request.headers.get("content-length")
if not length or int(length) > MAX_BYTES:
raise HTTPException(413, f"Max size is {MAX_MB} MB")
# stream safely
target = UPLOAD_DIR / file.filename
async with aiofiles.open(target, "wb") as out:
total = 0
while chunk := await file.read(1_000_000):
total += len(chunk)
if total > MAX_BYTES:
raise HTTPException(413, f"Max size is {MAX_MB} MB")
await out.write(chunk)
return {"ok": True, "bytes": total}
For stricter control, put a reverse proxy (Nginx, Traefik) in front with request body limits to stop oversize uploads before they reach Python.
Validate by content, not just the extension
Attackers rename files. Verify magic numbers or parse headers.
import imghdr
@app.post("/upload/image")
async def upload_image(file: UploadFile = File(...)):
head = await file.read(12) # small sniff
await file.seek(0) # rewind for streaming
if imghdr.what(None, head) not in {"png", "jpeg"}:
raise HTTPException(415, "Not a real image")
# stream as above
...
For PDFs and videos, use libraries that inspect headers (e.g., PyPDF2 for a basic sanity check, ffprobe for media).
Save metadata cleanly
Uploads are more useful with context: who uploaded, when, purpose, checksum. Put these in your database, not the filename.
import hashlib
from datetime import datetime
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1_000_000), b""):
h.update(chunk)
return h.hexdigest()
# After saving the file:
checksum = sha256_of(target)
# INSERT into uploads(user_id, path, checksum, content_type, created_at)
Checksums help deduplicate and detect corruption later.
Option: presigned URLs (the "no middleman" pattern)
If you expect very large files or mobile clients on flaky networks, don't stream through your API at all. Issue a short-lived presigned URL from your backend; the client uploads directly to object storage. Your API receives only a lightweight "upload complete" webhook or confirmation call. Result: less server load, better throughput, happier users.
(You can still reuse the validation ideas — mime type, size hints, and checksums — around the presign and confirm steps.)
Useful client snippets
HTML form
<form id="form" method="post" enctype="multipart/form-data" action="/upload">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>
cURL
curl -F "file=@report.pdf" http://localhost:8000/upload
JavaScript (progress bar)
const file = document.querySelector('input[type=file]').files[0];
const form = new FormData();
form.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload/stream', true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
console.log('Progress:', pct + '%');
}
};
xhr.send(form);
Error handling with empathy
Clear messages beat stack traces:
413 Payload Too Large
with your limit in MB415 Unsupported Media Type
with allowed types422 Unprocessable Entity
for malformed multipart data500
only for genuine server errors, and log the details server-side
Return consistent JSON so your frontend can display friendly hints.
Production checklist
- Request body limit at the proxy layer
- Chunked streaming to disk or cloud
- Strict content-type + magic-number checks
- Size caps per route and per user role
- Background post-processing (thumbnails, scans)
- Metadata in DB (user, checksum, content type, purpose)
- Retention policy and cleanup jobs
- Metrics: rate of uploads, average size, errors by code
Wrap-up
Uploads aren't glamorous, but they're where reliability shows. With UploadFile
, chunked streaming, and a few guardrails, FastAPI handles files like a pro—small to large, local to cloud, quick to resilient. Start with simple endpoints, layer in validation and background work, and you'll turn "uh-oh, someone sent a 600 MB AVI" into "cool, we've got it."
Your turn: What's your trickiest upload use case — huge files, weird types, or noisy clients? Drop a comment, follow for more Python backend patterns, and tell me what to dissect next.