How FastAPI File Upload Works and What It Leaves You to Build

Posted on | Last updated on
How FastAPI File Upload Works and What It Leaves You to Build

A FastAPI file upload is one parameter type and about four lines. What the four lines do not tell you is where the bytes are sitting while your handler runs, and that detail decides whether the endpoint survives a large file or a busy afternoon.

Key takeaways

  • Use UploadFile rather than bytes, because bytes holds the whole file in memory.
  • Read in chunks, since awaiting a full read undoes the reason for using UploadFile.
  • Strip the client’s filename to its basename, or a path traversal writes outside your directory.
  • FastAPI sets no size limit and validates nothing; both are yours to add.
  • A blocking call inside an async def handler stalls the event loop, not one thread.

The two ways to receive a file

FastAPI accepts multipart uploads through either bytes or UploadFile, and they behave very differently.

from fastapi import FastAPI, File, UploadFile

app = FastAPI()

@app.post("/upload-bytes")
async def upload_bytes(file: bytes = File()):
    return {"size": len(file)}

@app.post("/upload-file")
async def upload_file(file: UploadFile):
    return {"filename": file.filename, "type": file.content_type}

bytes reads the entire file into memory before your function starts. A 2 GB upload becomes 2 GB of RAM, and ten of them at once becomes an outage. It is fine for small, bounded things like an avatar and dangerous as a default.

UploadFile wraps a SpooledTemporaryFile, which keeps small files in memory and rolls larger ones onto disk automatically. It also gives you filename and content_type, and it is what you should reach for unless you have a specific reason not to.

Both need python-multipart installed, which FastAPI does not pull in for you:

pip install fastapi uvicorn python-multipart

Leaving it out produces an error at startup rather than at request time.

Reading the file without loading it

The underlying file object behaviour, and where it differs from a plain open handle, is covered in the guide to Python file object methods.

UploadFile is async, and the reason to care is memory again. Reading it whole undoes the point of using it:

@app.post("/upload")
async def upload(file: UploadFile):
    contents = await file.read()        # the whole thing, back in memory
    return {"size": len(contents)}

Streaming in chunks keeps usage flat regardless of file size:

import shutil
from pathlib import Path

Path("uploads").mkdir(exist_ok=True)

@app.post("/upload")
async def upload(file: UploadFile):
    dest = Path("uploads") / Path(file.filename).name   # strip any path from the name
    with dest.open("wb") as out:
        while chunk := await file.read(1024 * 1024):
            out.write(chunk)
    return {"saved": str(dest)}

Path(file.filename).name is doing real work there. The client controls that string, and a filename of ../../etc/passwd would otherwise resolve outside the upload directory.

Or hand the whole thing to shutil, which does the same in one line:

with dest.open("wb") as out:
    shutil.copyfileobj(file.file, out)

file.file is the underlying synchronous object, which is why copyfileobj works on it. That call is synchronous, blocking I/O even inside an async def handler, so treat it as a shortcut for small files and scripts rather than a drop-in replacement for the streaming version above. “Async, and whether it helps” below covers what that blocking costs.

Multiple files and extra fields

A list annotation gives you several files, and Form lets ordinary fields travel alongside them in the same request.

from fastapi import Form

@app.post("/upload-many")
async def upload_many(
    files: list[UploadFile],
    note: str = Form(""),
):
    return {"count": len(files), "note": note}

A request cannot mix Form and JSON body parameters. Multipart and JSON are different encodings, so anything arriving alongside the files has to be a form field. A client sending a JSON body next to a file gets a 422 response naming the field it could not parse.

What FastAPI does not do

FastAPI hands you the bytes and stops there.

It does not limit size. There is no maximum upload setting. A client can stream as much as it likes, and your server will keep accepting. The limit has to come from your reverse proxy, client_max_body_size in nginx, or from counting bytes as you read and aborting.

It does not validate the file. content_type comes from the client and is trivially forged. A renamed executable arrives claiming to be a PNG, and FastAPI passes it through because that is what the client said. Checking means reading the leading bytes yourself:

SIGNATURES = {b"\x89PNG\r\n\x1a\n": "image/png", b"\xff\xd8\xff": "image/jpeg"}

@app.post("/upload")
async def upload(file: UploadFile):
    head = await file.read(8)
    await file.seek(0)
    kind = next((v for sig, v in SIGNATURES.items() if head.startswith(sig)), None)
    return {"detected": kind}

Nothing scans the contents. Whatever the file contains is now on your disk. If users upload files that other users download, malware scanning belongs in the pipeline, and the general ground is covered in file upload security best practices.

It does not report progress. The client sees the request complete or not. Progress bars require either chunked uploads your client drives, or a service that reports it.

It does not store anything durably. Writing to local disk works until you run two instances of the application, at which point half your uploads are on the wrong machine.

Naming what you store

Two decisions about filenames matter.

The first is whether to keep the client’s name at all. Duplicate names are common, since scanners default to names like scan.pdf. Storing by the original name means the second upload overwrites the first, silently, and the person who lost a document has no way to know. Generating your own identifier and keeping the original name as a display label avoids the whole class of problem.

The second is character handling. Filenames arrive with spaces, accents, emoji and occasionally control characters, and every layer they pass through treats them differently. A name that works on your laptop can break a signed URL, an email attachment header or a Windows client. Storing by identifier means none of those layers ever sees the user’s string.

import uuid
from pathlib import Path

stored = f"{uuid.uuid4()}{Path(file.filename).suffix.lower()}"

Keep the extension, because it carries the type through systems that only look at the name, and lowercase it, because case-sensitive storage will otherwise treat .PNG and .png as different things.

Join the Filestack developer community on Discord

The architectural question underneath

The version above routes every byte through your application. That is the simplest thing to build and the first thing to become a bottleneck, because an upload occupies a worker for its entire duration. Ten slow clients on a hotel connection can hold ten workers for minutes each while your API stops answering anything else.

The alternative is to let the browser send the file directly to storage and have your server handle only the metadata. The endpoint then issues a short-lived credential, the file never touches your machine, and the worker is free immediately.

That pattern is what managed upload services provide, and it changes what your FastAPI code is responsible for: not receiving files, but deciding who may upload what, and recording what arrived. The guide to integrating FastAPI with the Python SDK wires that arrangement up end to end.

@app.post("/attachments")
async def record(handle: str = Form(), filename: str = Form()):
    # the file is already stored; save the reference and move on
    return {"url": f"https://cdn.filestackcontent.com/{handle}", "filename": filename}

A handle is all your database needs. The file is addressable from it, and no API key belongs in that URL because the handle already identifies the application.

When the file is a document

Uploading is often the least interesting part of the requirement. If what arrives is a PDF or a scan, the actual job is usually reading it, and that turns an upload endpoint into a document pipeline.

Text extraction and recognition can run as steps against the stored file rather than code you maintain, which is what an ocr api is for. The document capture and data extraction page sets out which plans include recognition.

A working endpoint to start from

The server-side half of this lives in the Python file upload SDK.

import uuid
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()
Path("uploads").mkdir(exist_ok=True)
MAX_BYTES = 10 * 1024 * 1024
ALLOWED = {"image/png", "image/jpeg", "application/pdf"}

@app.post("/upload")
async def upload(file: UploadFile):
    if file.content_type not in ALLOWED:
        raise HTTPException(415, f"{file.content_type} not accepted")

    # store by generated identifier, keep the client's name as a label only
    suffix = Path(file.filename or "").suffix.lower()
    dest = Path("uploads") / f"{uuid.uuid4()}{suffix}"
    total = 0
    with dest.open("wb") as out:
        while chunk := await file.read(1024 * 1024):
            total += len(chunk)
            if total > MAX_BYTES:
                dest.unlink(missing_ok=True)
                raise HTTPException(413, "file too large")
            out.write(chunk)
    return {"stored": dest.name, "original": file.filename, "bytes": total}

content_type is the client’s claim, so this endpoint accepts a renamed file. Add the signature check from “What FastAPI does not do” when the file goes anywhere other than your own disk.

Path(file.filename).name is the line that matters most and the one most examples omit. A client can send ../../etc/passwd as a filename, and joining that to a directory writes exactly where it says. Stripping to the basename is the difference between an upload endpoint and a path traversal.

Testing an upload endpoint

Upload endpoints are awkward to test by hand and straightforward to test in code. TestClient needs httpx installed, which the earlier install line does not cover:

pip install httpx
from fastapi.testclient import TestClient

client = TestClient(app)

def test_accepts_png():
    r = client.post("/upload", files={"file": ("a.png", b"\x89PNG\r\n\x1a\n" + b"0" * 100, "image/png")})
    assert r.status_code == 200

def test_rejects_type():
    r = client.post("/upload", files={"file": ("a.exe", b"MZ", "application/x-msdownload")})
    assert r.status_code == 415

def test_rejects_oversize():
    big = b"0" * (11 * 1024 * 1024)
    r = client.post("/upload", files={"file": ("big.png", big, "image/png")})
    assert r.status_code == 413

The third test proves the size limit runs while reading rather than after, which is the difference between rejecting a large upload and absorbing it first.

Add a fourth for the traversal case, sending ../../evil.png as the filename and asserting that the response’s stored name is a generated identifier ending in .png, with nothing written outside the upload directory.

Async, and whether it helps

FastAPI being async does not make uploads faster. The bytes arrive at whatever speed the client sends them, and no amount of concurrency changes that.

What async does is stop a slow upload blocking everything else. With async def and awaited reads, a worker handling a slow client yields between chunks and serves other requests in between. With def and synchronous reads, FastAPI runs the handler in a threadpool, which works but caps concurrency at the pool size.

The practical consequence is that mixing the two carelessly hurts. An async def handler that calls a blocking library, writing to disk with a plain open or uploading to storage with a synchronous client, blocks the whole event loop rather than one thread. That is worse than having written a synchronous handler in the first place.

If a step in your pipeline is synchronous and slow, either use an async client for it or push the work to a background task and return immediately.

Where to go from here

For an internal tool with known users and small files, the endpoint above is enough. For anything public, the questions that follow are size limits at the proxy, content checking beyond the declared type, durable storage that survives a second instance, and whether the bytes should be passing through your application at all.

Those four arrive in roughly that order as traffic grows, and each one is a day or two of work on its own.

FAQ

Should I use bytes or UploadFile?

UploadFile in almost every case. It spools to disk once a file is large enough, gives you filename and content_type, and keeps memory flat. Reach for bytes only when the file is small and bounded, such as an avatar.

Why does my app fail to start after adding an upload endpoint?

python-multipart is missing. FastAPI does not install it, and the error arrives at startup rather than on the first request, which is why it looks unrelated to the endpoint you just added.

How do I stop someone uploading a huge file?

FastAPI has no size setting, so the limit comes from your reverse proxy or from counting bytes as you read and raising once the total passes your maximum. Checking after the read has already absorbed the file.

Is checking content_type enough to validate a file?

No. It is the client’s claim and trivially forged, so a renamed executable arrives declaring itself a PNG. Read the leading bytes and compare against known signatures whenever the file goes anywhere beyond your own disk.

 

 

Read More →