Back to Blog
Engineering

How to Handle File Uploads in FastAPI: Validation, Limits, and the Traps

Eleven FastAPI guides on this blog — auth, rate limiting, error shapes, testing — and none on the feature that changes what your API fundamentally is. Every other endpoint accepts structured data: JSON that Pydantic validates into shapes you chose. An upload endpoint accepts arbitrary bytes from strangers, to keep. It is the closest thing a well-behaved API has to an open loading dock, and it deserves to be built like one. We run upload endpoints in four production apps; what follows is the gauntlet those implementations converged on, with the real constants — and one honest confession about a trap the oldest of them still walks into.

Think Like the Attacker: The Gauntlet in Defeat Order

Most upload tutorials present validation as a checklist. It's better understood as a sequence of walls, ordered by how cheaply each one is defeated — because that ordering tells you which walls are load-bearing and which are just politeness:

Walls 1 & 2 — Content-type header, then extension: politeness, not security

The Content-Type header is a client-supplied string. So is the filename and therefore its extension. An attacker sets both to whatever you want to hear with one line of curl. These checks exist to give honest users fast, clear errors — they reject the accidental PDF, not the deliberate payload. Keep them, and trust them for nothing.

Wall 3 — The decode: the first wall that touches the truth

The only proof that bytes are an image is decoding them as one. Image.open() succeeding is a fact about the actual content; everything before it was a fact about the request's manners. This is where a renamed executable dies, regardless of what its headers claimed.

Wall 4 — The pixel-count guard: the wall for files that decode on purpose

Decompression bombs are tiny files that decode into enormous images — a few kilobytes of highly compressible pixels expanding into gigabytes of RAM. Your size cap never sees the danger; the file is small. The guard is a ceiling on decoded size, and Pillow ships it as one assignment.

Walls 5–7 — Size cap, re-encode, storage: the boring walls that do the most work

Cap the bytes (enforced before they're buffered — the trap section below is about exactly this), then re-encode the decoded image into a fresh file of your own making, then store it under a name the client never influenced. After the re-encode, nothing the uploader crafted survives — not metadata, not appended payloads, not the original bytes at all.

The Constants, From Production

These are the actual values from our dating app's upload module — not recommendations we haven't lived with:

from PIL import Image

# The single most valuable line in this article. A ceiling on DECODED size:
# a 40KB file that inflates to a 2GB bitmap dies here, not in your RAM.
Image.MAX_IMAGE_PIXELS = 25_000_000  # ~5000x5000 max, prevents decompression bombs

MAX_FILE_SIZE = 10 * 1024 * 1024   # 10MB cap on the wire bytes
MAX_DIMENSION = 1200               # longest side after processing
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}

Note that the extension set and the content-type set are both present. That's not redundancy paranoia — they reject different accidents cheaply (a mis-named file, a mis-configured client), and neither is trusted past the decode. Pillow raising DecompressionBombError when the pixel ceiling is exceeded is the guard actually firing; catch it and return a 400 rather than letting it surface as a 500.

The Trap: await file.read() Defeats Your Own Streaming

FastAPI's UploadFile is built politely: it spools the incoming body to a temp file, so a large upload doesn't automatically become large memory. Then almost every handler ever written — including, in the spirit of full disclosure, our own oldest one — does this:

async def upload_photo_the_common_way(file):
    # The trap: this buffers the ENTIRE body into memory first...
    content = await file.read()

    # ...and only then asks whether we wanted it. The check passes
    # judgment on bytes that already cost you the RAM.
    if len(content) > MAX_FILE_SIZE:
        raise ValueError("File too large")
    return content

The cap still rejects the file — but after paying full price to hold it. One oversized request is survivable; the failure mode is concurrency, where a handful of simultaneous multi-hundred-megabyte bodies compete for your worker's memory before any of them is refused. The fix costs ten lines: enforce the cap while reading, so the request dies at the first chunk past the limit:

CHUNK = 64 * 1024

async def read_capped(file, cap: int) -> bytes:
    # Reject at the boundary: the (cap+1)th kilobyte is never buffered.
    chunks, total = [], 0
    while True:
        piece = await file.read(CHUNK)
        if not piece:
            break
        total += len(piece)
        if total > cap:
            raise ValueError("File too large")
        chunks.append(piece)
    return b"".join(chunks)

And put a wall in front of the wall: your reverse proxy should enforce a body-size limit of its own (nginx calls it client_max_body_size), so the truly absurd request is refused before Python ever schedules a coroutine. Defense in depth here is cheap and each layer catches what the previous one structurally can't.

Decode, Guard, Re-encode: One Function Does the Real Work

import io
from PIL import Image

def process_image(data: bytes, max_dimension: int = 1200) -> bytes:
    try:
        img = Image.open(io.BytesIO(data))  # wall 3: the only proof it's an image

        if img.mode != "RGB":
            img = img.convert("RGB")        # flatten alpha/palette for JPEG

        img.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS)

        out = io.BytesIO()
        img.save(out, format="JPEG", quality=85, optimize=True)
        return out.getvalue()               # a NEW file. nothing of theirs survives

    except Image.DecompressionBombError:
        raise ValueError("Image dimensions too large")  # wall 4 fired
    except ValueError:
        raise
    except Exception:
        raise ValueError("Not a decodable image")       # wall 3 fired

The re-encode is the wall people skip and shouldn't, because it does three jobs at once. Security: whatever cleverness lived in the original bytes — polyglot files that are simultaneously valid images and valid something-else, payloads appended after the image data — does not survive being decoded to pixels and re-saved as a fresh JPEG. Privacy: re-encoding drops EXIF metadata, and EXIF is where phones write GPS coordinates; a dating app that stored originals would be redistributing its users' home addresses to anyone who can right-click. That is not a hypothetical — it's the reason this step is non-negotiable in our stack. Economics: every stored image is now a bounded-size JPEG at quality 85, which is what makes your storage math predictable.

Storage: The Client Named the File; Ignore Them

import os
import uuid

def storage_path(upload_dir: str, user_id: int) -> str:
    # Server-generated name, per-user directory. The uploader's filename
    # is never a path component, so "../../etc/cron.d/x" is just a string.
    return os.path.join(upload_dir, str(user_id), f"{uuid.uuid4()}.jpg")

Path traversal only exists when client input reaches the filesystem path. A UUID name plus a directory derived from the authenticated user's id ends the entire category — there is nothing to sanitize because nothing of theirs is used. Two adjacent rules from the same module: serve uploads from a static route or a separate host, never by echoing paths through your app; and on deletion, commit the database first, remove the file second. Our delete handler is commented with exactly that ordering, and the reasoning generalizes: a file with no row is garbage you can sweep later; a row with no file is a broken profile a user is looking at right now. The upload path mirrors it — if anything fails after the file hits disk, the handler rolls back the transaction and removes the just-written file, so neither side of the ledger ends up pointing at nothing.

What This Design Does Not Do

Two honest limits

First: the allowlists are not the security. If your review of an upload endpoint stops at “it checks the extension and the content type,” it checks nothing an attacker controls the truth of — the decode, the pixel guard, and the re-encode are the walls that hold. Second: we do not scan uploads for malware, and this article hasn't shown you how. For an image pipeline that re-encodes everything, the re-encode is the meaningful mitigation — but if you accept files you store as received (PDFs, zips, documents), you are in different territory that needs real scanning infrastructure, and pretending an allowlist covers it would be exactly the kind of claim we try not to make.

The Checklist, In One Place

In the order the request experiences it: proxy body cap → extension and content-type allowlists (politeness) → size cap enforced during the read, not after → decode as proof of format → Image.MAX_IMAGE_PIXELS against bombs → re-encode to a fresh JPEG (security, EXIF privacy, predictable storage) → UUID filename in a per-user directory → DB-first deletion ordering. Eight decisions, each one line to a dozen — and if you're deploying the result, the VPS deploy guide covers the nginx side, including where that body-size limit lives.

Test the Walls, Not the Vibes

Every wall in this article is assertable — the static and integration patterns for proving your upload gauntlet holds are in the testing guide.

How to Test a FastAPI App
BW

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: What $918 of Ads Bought All Articles