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 over SpooledTemporaryFile, 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 MB
  • 415 Unsupported Media Type with allowed types
  • 422 Unprocessable Entity for malformed multipart data
  • 500 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.