Skip to content

Request size limits

Severity: lowApplies to: Starlette 1.6+ (RequestBodyLimitMiddleware)Applies to: Starlette 1.5 and earlier (no built-in)Applies to: FastAPI 0.100+Facts last verified 2026-08-13 against Starlette 1.6.0 · FastAPI 0.141.1 · uvicorn 0.52.3

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.

settings on this page
max_body_sizeoption

Maximum 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
Read in: starlette 1.6.0 starlette/middleware/body_limit.py — RequestBodyLimitMiddleware.__init__(self, app, max_body_size: int), no default; reproduced
starlette.max_body_size

The 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
Read in: starlette 1.6.0 starlette/middleware/body_limit.py — MAX_BODY_SIZE_SCOPE_KEY
if you see this
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.0

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.

the fix
Starlette 1.6.0+
from fastapi import FastAPI
from 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.

The middleware does not exist, so it has to be assembled — and it takes two pieces for the reason above:

Starlette 1.5 and earlier
from fastapi import FastAPI, HTTPException, Request, UploadFile, status
from fastapi.responses import JSONResponse
MAX_BODY = 2 * 1024 * 1024 # 2 MB for JSON
MAX_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.

verify it workedRun this in: http response
Terminal window
# generate something larger than your intended cap and send it
head -c 50000000 /dev/zero > /tmp/big.bin
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST https://api.example.com/upload \
-F 'f=@/tmp/big.bin'
rm /tmp/big.bin

413 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:

Terminal window
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.json

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.

before you ship this

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:

Terminal window
# largest request bodies seen, if your access log records size
awk '{print $10}' access.log | sort -rn | head -20

Set 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.