Request size limits
There is still no default body-size cap, but as of Starlette 1.6.0 there is a
built-in way to set one. Until that release Starlette defined
HTTP_413_CONTENT_TOO_LARGE and never raised it; RequestBodyLimitMiddleware now
does. It is opt-in, and max_body_size is a required argument — so the middleware
cannot be installed in a permissive state, and an application that does not install it
is exactly as unbounded as before.
Which means the exposure is unchanged for anyone who has not added it: an endpoint
accepting JSON will read a body of any size into memory, and an endpoint accepting
UploadFile will accept a file of any size.
Check which side of the line you are on before assuming either — FastAPI pins
starlette>=0.46.0 with no upper bound, so a fresh install picks up 1.6.0 while a
lockfile written before 2026-08-08 does not.
max_body_sizeoptionMaximum total request body the middleware will accept. There is no default — the argument is required, so the middleware cannot be installed in a permissive state. Enforcement is two-sided: an oversized Content-Length is rejected before the body is read, and the actual bytes are counted as they arrive, so a understated or absent Content-Length is still caught.
- accepts
any int, in bytes — e.g. 2 * 1024 * 1024- set in
app.add_middleware(RequestBodyLimitMiddleware, ...)- since
Starlette 1.6.0
starlette.max_body_sizeThe scope key the middleware writes the active limit into, and re-reads if it is nested — an inner instance overrides the outer one for the remainder of the request. Useful if you want a different cap on one route than the app-wide default.
- set in
ASGI scope key- since
Starlette 1.6.0
RuntimeError: Form data requires "python-multipart" to be installed.A route declared UploadFile or Form without the parser installed. Worth knowing because it is raised at request time rather than at import — the application starts cleanly and the endpoint fails on first use, so a deploy can look healthy until someone uploads something.
Read in: reproduced on FastAPI 0.141.0Where an upload actually goes
Section titled “Where an upload actually goes”UploadFile is backed by a SpooledTemporaryFile: small uploads stay in memory, and
once the spool threshold is crossed the content is written to a temporary file on disk.
That is a good design and it changes the failure rather than removing it. Instead of
one large request exhausting memory, many concurrent uploads exhaust the container’s
disk or its /tmp — and a full disk takes down everything else in the container, not
just the upload endpoint.
It is also why “we stream the upload” is not a limit. Streaming controls where the bytes accumulate, not whether they do.
from fastapi import FastAPIfrom starlette.middleware.body_limit import RequestBodyLimitMiddleware
app = FastAPI()app.add_middleware(RequestBodyLimitMiddleware, max_body_size=2 * 1024 * 1024)That is the whole control on 1.6.0 and later. It covers both halves of the problem,
which is worth spelling out because the hand-rolled version below needed two pieces to
do it: an oversized Content-Length is rejected before the body is read, and the bytes
that actually arrive are counted as they stream, so a client that understates the length
or sends none at all is still stopped.
One operational detail that will show up in your logs: the same limit returns two different response bodies depending on which half caught it.
| Request | Response |
|---|---|
Honest oversized Content-Length |
413 · text/plain · Content Too Large |
Understated Content-Length |
413 · application/json · {"detail":"Content Too Large"} |
Chunked, no Content-Length |
413 · application/json · {"detail":"Content Too Large"} |
The header pre-check short-circuits to a plain-text response; the streaming check raises
an HTTPException, which FastAPI’s handler renders as JSON. If you alert on response
bodies rather than status codes, match both.
Below Starlette 1.6.0
Section titled “Below Starlette 1.6.0”The middleware does not exist, so it has to be assembled — and it takes two pieces for the reason above:
from fastapi import FastAPI, HTTPException, Request, UploadFile, statusfrom fastapi.responses import JSONResponse
MAX_BODY = 2 * 1024 * 1024 # 2 MB for JSONMAX_UPLOAD = 20 * 1024 * 1024 # 20 MB for files
@app.middleware("http")async def limit_body(request: Request, call_next): declared = request.headers.get("content-length") if declared is not None and int(declared) > MAX_UPLOAD: return JSONResponse( {"detail": "Request body too large"}, status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, ) return await call_next(request)
@app.post("/upload")async def upload(f: UploadFile): read = 0 while chunk := await f.read(64 * 1024): read += len(chunk) if read > MAX_UPLOAD: raise HTTPException(413, "File too large") ...Content-Length is a claim. The middleware rejects the honest oversized request cheaply,
before the body is read. The chunked loop is what handles a client that lies about the
length or uses Transfer-Encoding: chunked and sends no length at all — it counts what
actually arrives and stops.
A middleware alone is a request-size hint. The loop is the enforcement. On 1.6.0+ the built-in does both, which is the reason to prefer it over keeping this.
The proxy in front of you may already cap this
Section titled “The proxy in front of you may already cap this”Most reverse proxies impose a default body limit, and it is often around one megabyte — which means the exposure may already be closed for traffic that arrives through the proxy, and the symptom you meet first is the opposite one: legitimate uploads rejected before they reach your code.
Two things follow. Check what the edge allows before assuming the application is exposed, because it changes this from an open hole to defence in depth. And when an upload fails, work out which layer rejected it — a proxy-generated error page looks nothing like a FastAPI JSON response, and that difference is the fastest way to tell them apart.
The application-side limit still earns its place: it covers anything that reaches the application port without passing the proxy, and it keeps the limit next to the code that knows what a reasonable size is.
# generate something larger than your intended cap and send ithead -c 50000000 /dev/zero > /tmp/big.bincurl -s -o /dev/null -w '%{http_code}\n' \ -X POST https://api.example.com/upload \ -F 'f=@/tmp/big.bin'rm /tmp/big.bin413 is correct, and it should come back quickly — a fast rejection means the size was
caught before the whole body was read. A slow 413 means you accepted 50 MB and then
decided against it, which is the exhaustion you were trying to prevent.
Test the dishonest case too, since that is the one the middleware alone misses:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.example.com/items \ -H 'Content-Type: application/json' -H 'Transfer-Encoding: chunked' \ --data-binary @/tmp/medium.jsonDeclared type is not verified content
Section titled “Declared type is not verified content”UploadFile.content_type is whatever the client put in the multipart part. It is not
sniffed and not validated, so it says nothing about the bytes.
Anything that branches on it — storing under a derived extension, deciding whether to
render inline, passing it to a converter — is trusting the uploader. Check the content
itself, and never build a filesystem path out of UploadFile.filename, which is also
client-supplied.
A size cap breaks the legitimate large upload, and the size that is “legitimate” is usually discovered after the limit ships — a video, a database export, a batch import that was always 40 MB and nobody mentioned.
Measure before choosing the number:
# largest request bodies seen, if your access log records sizeawk '{print $10}' access.log | sort -rn | head -20Set the cap above the observed maximum with headroom, then tighten later if you want.
Make the rejection legible — a bare 413 with no body leaves the user with a failed
upload and no reason, and support cannot distinguish it from a network problem. Return
the limit in the message, and make sure the front end surfaces it rather than treating
any non-2xx as a generic failure.
Related
Section titled “Related”- Rate limiting — bounding how often, where this bounds how much
- Validation error disclosure — the other thing a rejected request returns