A 413 Payload Too Large on your upload endpoint is not one bug. It is three limits, set in three places, that nobody aligned. Fix it in only one of them and you either keep the 413, or you trade it for something worse: a 2 GB request buffered into memory, or a file quietly truncated on the way in.
The reason this fix is fiddly is that the request body passes through more than one gatekeeper. On a typical nginx 1.27 in front of FastAPI 0.115 on Python 3.12, the body is checked by the reverse proxy, then by the framework’s multipart parser, then handled by your route. Each has its own ceiling and its own failure mode. Raise one, and the next one downstream catches the request. The only durable fix is to size all of them deliberately and to stream the bytes rather than hold them.
Where the 413 actually comes from
Nine times out of ten the 413 is nginx, not your application. nginx ships with client_max_body_size set to 1m — one megabyte. Any request whose body exceeds that is rejected with 413 Request Entity Too Large before a single byte reaches your upstream. Your FastAPI logs will be silent, because FastAPI never saw the request. That silence is the tell: if the app log is empty and the client gets a 413, look at the proxy first.
Raising that limit is one line, but do it with a number you have chosen, not 0 (which disables the check entirely and hands you a denial-of-service vector). Set it to the largest file you actually intend to accept.
# /etc/nginx/conf.d/uploads.conf
server {
listen 443 ssl;
server_name api.example.com;
# Reject oversized bodies at the edge. Default is 1m.
client_max_body_size 2g;
# Give slow clients time to send a large body without
# tying up a worker indefinitely. Default is 60s.
client_body_timeout 300s;
location /v1/uploads {
# Stream the body to the upstream instead of buffering
# the whole 2 GB to a temp file before forwarding it.
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_pass http://127.0.0.1:8000;
}
}
Two flags earn their place here. client_body_timeout governs how long nginx waits between successive reads of the body; a large upload over a slow link can exceed the 60-second default and be cut off mid-transfer. And proxy_request_buffering off stops nginx doing the thing that surprises people: by default it buffers the entire request body — to memory up to client_body_buffer_size, then to a temp file on disk — before it forwards anything upstream. For a 2 GB upload that is a full disk write you did not ask for. Turning buffering off lets the bytes flow straight through to FastAPI.
The second limit: FastAPI’s multipart parser
Clear nginx and the request reaches Starlette, the layer underneath FastAPI. If you are accepting a multipart/form-data upload — the usual browser form — the parser has its own ceiling. Each part is capped at max_part_size, which defaults to 1048576 bytes (1 MiB). A part larger than that raises a parsing exception rather than truncating, so at least the failure is loud. But it is a second, independent 1 MB wall that many people never knew existed, and it produces a 400, not a 413, which sends the debugging off in the wrong direction.
There is a related trap. When you accept an UploadFile, Starlette spools it to a SpooledTemporaryFile that keeps the first 1048576 bytes in RAM and only rolls over to disk past that point. That is sensible for small files. It is also why a naive contents = await file.read() on a large upload pulls the whole thing into memory — you have re-created the buffering problem you just switched off in nginx, one layer up. Read an UploadFile in chunks, or bypass multipart for genuinely large payloads.
Stream to disk, and enforce the ceiling on real bytes
For large files, take the raw body as a stream, write it to disk (or forward it to object storage) in fixed chunks, and enforce the size limit on the bytes you have actually received — not on the Content-Length header, which a client can understate or omit. Reject early when the header is honestly too big, and again if the real count overruns.
# app.py — FastAPI 0.115 on Python 3.12
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, status
app = FastAPI()
UPLOAD_DIR = Path("/var/lib/api/uploads")
MAX_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB, matched to client_max_body_size
@app.post("/v1/uploads")
async def upload(request: Request):
# Reject on a truthful Content-Length before reading a byte.
declared = request.headers.get("content-length")
if declared is not None and int(declared) > MAX_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
"File exceeds the 2 GiB limit")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
dest = UPLOAD_DIR / "incoming.bin"
written = 0
with dest.open("wb") as fh:
async for chunk in request.stream():
written += len(chunk)
# Content-Length can lie; enforce on the real byte count.
if written > MAX_BYTES:
dest.unlink(missing_ok=True)
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
"File exceeds the 2 GiB limit")
fh.write(chunk)
return {"bytes": written}
Memory stays flat regardless of file size, because only one chunk is resident at a time. The Content-Length check is the useful-error-before-the-bytes-arrive part: a well-behaved client is told no immediately instead of uploading two gigabytes to discover it was never going to be accepted. If you are pushing to S3 or a compatible store, feed the same stream to a multipart-upload call and skip local disk entirely.
One caveat: request.stream() reads the raw body, so it is the path for a direct binary POST or PUT. If you must accept browser multipart, either raise max_part_size to match, or — better for large files — move to a presigned direct-to-storage upload and keep the big bytes off your application entirely.
Silent truncation is the failure to actually fear
A 413 is annoying but honest — the client knows it was rejected. The dangerous failure is the upload that returns 200 with a file that is short. That happens when the limits disagree: a client aborts and your handler treats a partial read as complete, or a timeout severs the body mid-stream and nothing checks the received length against what was promised. The defence is the same discipline as above — compare bytes written against the declared Content-Length at the end, and fail if they differ. A stored file that is quietly 40% of its intended size is far harder to notice, months later, than a 413 in a log.
Upload endpoints get retried, too, especially the large ones that fail on flaky connections. If a client re-sends a 2 GB file after a timeout, you want that to be safe rather than duplicated — which is the same problem covered in making POST safe to retry with idempotency keys. And because an upload route accepts arbitrary bytes from the outside, it should sit behind proper authentication and scoping, along the lines of API keys that aren’t a liability.
When you ship the fix, watch the 413 rate as a first-class signal rather than a support ticket. A spike after a deploy usually means a limit drifted — the nginx config was templated with the old default, or a new part exceeded the parser cap — and it is exactly the kind of thing worth catching in the same change-failure instrumentation you already run for the DORA four metrics.
Size the proxy, size the parser, stream the body, and check the byte count at the end. Three limits, one direction of travel: reject fast, buffer nothing, and never store a file you cannot prove arrived whole.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.