Request Object & Content-Type
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All code runs on Python 3.10 or newer and was executed with TestClient on Python 3.13. Normally a path
operation declares the pieces of the request it needs and FastAPI validates and documents them (see
Path Parameters, Query Parameters
and Request Body & Models). This page covers the three situations
where you step below that layer: reading the raw Starlette Request, understanding why a JSON body without a
Content-Type header is no longer parsed (a breaking change in 0.132.0), and replacing the Request and APIRoute
classes themselves.
Using the Request Object Directly
FastAPI is Starlette with a validation layer on top, so Starlette’s Request is available as is. Declare a parameter
annotated with Request and FastAPI passes the current request in; the parameter is not part of the OpenAPI schema and
may sit next to ordinary path, query and body parameters, which are still extracted, converted and validated as usual.
Request can be imported from fastapi (a convenience re-export) or from starlette.requests.
What you read from the Request yourself is not validated, converted or documented: FastAPI knows nothing about it.
Reach for the object only when no declarative parameter can express what you need.
Client, URL, Headers and Path Parameters
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/books/{book_id}")
def read_book(book_id: int, request: Request) -> dict:
return {
"book_id": book_id,
"client": request.client.host if request.client else None,
"method": request.method,
"path": request.url.path,
"query": request.url.query,
"full_url": str(request.url),
"base_url": str(request.base_url),
"path_params": request.path_params,
"query_params": dict(request.query_params),
"user_agent": request.headers.get("user-agent"),
"route_url": str(request.url_for("read_book", book_id=7)),
}
c = TestClient(app)
print(c.get("/books/3?lang=en", headers={"User-Agent": "demo/1.0"}).json())
print(c.get("/books/abc").status_code)
The response of the first call, formatted:
{
"book_id": 3,
"client": "testclient",
"method": "GET",
"path": "/books/3",
"query": "lang=en",
"full_url": "http://testserver/books/3?lang=en",
"base_url": "http://testserver/",
"path_params": {"book_id": "3"},
"query_params": {"lang": "en"},
"user_agent": "demo/1.0",
"route_url": "http://testserver/books/7"
}
The second call (/books/abc) returns 422: the declared book_id: int is still validated even though a Request
is present. The attributes used above:
| Attribute | Meaning |
|---|---|
|
An address object with |
|
The HTTP method as an upper-case string. |
|
The full URL. It has |
|
The URL of the application root, including any mount path. |
|
A case-insensitive, immutable mapping; |
|
The raw, unvalidated query string as a multi-dict. |
|
The raw path parameters as matched by the router. They are strings here; FastAPI does the conversion for the declared parameters, not for this dictionary. |
|
The parsed |
|
Builds an absolute URL for a route by its function name (or |
|
The application instance handling the request. |
|
The ASGI scope dictionary underlying everything above. |
The full list is in the Starlette documentation: Starlette: Requests.
Per-Request State with request.state
request.state is a namespace that lives for the duration of one request. A middleware can attach values to it and a
dependency or path operation can read them back; this is the usual way to pass a request id, a timing start or the
authenticated user down the call chain without globals.
import time
import uuid
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request.state.request_id = str(uuid.uuid4())
request.state.started = time.perf_counter()
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
return response
def request_id(request: Request) -> str:
return request.state.request_id
RequestId = Annotated[str, Depends(request_id)]
@app.get("/ping")
def ping(rid: RequestId) -> dict:
return {"request_id": rid}
c = TestClient(app)
r = c.get("/ping")
print(r.json()["request_id"] == r.headers["x-request-id"], len(r.headers["x-request-id"]))
Source: FastAPI: Middleware
The prints True 36: the id the dependency saw is the same one the middleware put in the X-Request-ID response
header. Attributes missing from state raise AttributeError, so read optional values with getattr(request.state,
"name", None). Application-wide objects such as a database pool belong in lifespan state rather than in a
per-request attribute; see Settings, Lifespan &
Background Tasks. The same Request is also what a dependency receives when it declares the parameter; see
Dependency Injection.
The Raw Body: body(), json() and stream()
The body is read lazily, and only the methods you call touch it:
-
await request.body()returns all the bytes and caches them on the request. -
await request.json()parses the cached body as JSON. -
request.stream()is an async iterator that yields the body chunk by chunk as it arrives, without holding it all in memory. -
await request.form()parses form data and needspython-multipart; see Forms & File Uploads.
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.post("/raw")
async def raw(request: Request) -> dict:
body = await request.body()
return {"length": len(body), "content_type": request.headers.get("content-type")}
@app.post("/json")
async def as_json(request: Request) -> dict:
data = await request.json()
return {"keys": sorted(data)}
c = TestClient(app)
print(c.post("/raw", content=b"\x00\x01\x02", headers={"Content-Type": "application/octet-stream"}).json())
print(c.post("/json", json={"title": "Dune", "year": 1965}).json())
print(c.get("/openapi.json").json()["paths"]["/raw"]["post"].get("requestBody"))
Both calls work, and the last print is None: a path operation that only takes a Request has no requestBody in
the OpenAPI schema, so generated clients and the interactive documentation do not know the endpoint accepts a body.
When you need an arbitrary body and documentation, declare the body (for example Annotated[bytes, Body()] or
a model) instead of reading the Request.
Streaming the Body and Enforcing a Size Limit
stream() is the tool for bodies you do not want to buffer, and it is the place to enforce a size limit in
application code: count bytes as chunks arrive and stop at the limit. A Content-Length check rejects honest oversized
requests early, but the header can be absent (chunked transfer) or wrong, so the running count is the real guard.
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
MAX_BYTES = 1024 # also enforce in the reverse proxy
@app.post("/covers/raw", status_code=201)
async def upload_raw(request: Request) -> dict:
declared = request.headers.get("content-length")
if declared is not None and int(declared) > MAX_BYTES:
raise HTTPException(status_code=413, detail="Request body too large")
received = 0
chunks: list[bytes] = []
async for chunk in request.stream():
received += len(chunk)
if received > MAX_BYTES:
raise HTTPException(status_code=413, detail="Request body too large")
chunks.append(chunk)
return {"stored_bytes": received}
c = TestClient(app)
print(c.post("/covers/raw", content=b"x" * 100).json())
r = c.post("/covers/raw", content=b"x" * 2000)
print(r.status_code, r.json())
def gen():
for _ in range(5):
yield b"y" * 400 # chunked upload: no Content-Length header
r = c.post("/covers/raw", content=gen())
print(r.status_code, r.json())
The output is the stored size for the small body and 413 twice for the oversized ones: once from the declared length
and once from the running count of a chunked upload that sends no Content-Length. This guards the application layer
only; the reverse proxy (client_max_body_size in nginx, an equivalent setting elsewhere) should refuse oversized
bodies before they reach Python, and a limit declared in the application cannot protect a body that a declarative
parameter such as Annotated[bytes, Body()] or a form has already read completely. Chunked
uploads of files to disk or an object store are covered in
Forms & File Uploads; for storing them see the site’s
S3 and Azure Blob Storage pages.
Reading the Body More Than Once
The body arrives from the server once. Starlette caches what body() returned, and stream() replays that cache
afterwards, but a body consumed only through stream() is gone:
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Review(BaseModel):
rating: int
@app.post("/reviews")
async def create(review: Review, request: Request) -> dict:
raw = await request.body() # FastAPI already read it; Starlette caches it
again = [c async for c in request.stream()] # replays the cached body
return {"rating": review.rating, "raw": raw.decode(), "replayed_bytes": sum(len(c) for c in again)}
@app.post("/twice")
async def twice(request: Request) -> dict:
first = [c async for c in request.stream()]
try:
second = [c async for c in request.stream()]
except RuntimeError as e:
return {"first_bytes": sum(map(len, first)), "error": str(e)}
return {"first_bytes": sum(map(len, first)), "second_bytes": sum(map(len, second))}
c = TestClient(app)
print(c.post("/reviews", json={"rating": 5}).json())
print(c.post("/twice", content=b"abc").json())
The first call prints {'rating': 5, 'raw': '{"rating":5}', 'replayed_bytes': 12}: FastAPI had already read the
body to validate the Review model, and body() and stream() both still see it. The second prints
{'first_bytes': 3, 'error': 'Stream consumed'}: iterating stream() a second time raises RuntimeError. If a
body must be read twice, call await request.body() once and keep the bytes.
Strict Content-Type Checking
Since FastAPI 0.132.0 (2026-02-23) a request body is parsed as JSON only when the request says it is JSON. The rule,
implemented in fastapi/routing.py:
-
Content-Type: application/json, anyapplication/*+jsontype (such asapplication/vnd.api+json), and parameters such as; charset=utf-8are parsed as JSON. -
A body with no
Content-Typeheader is not parsed as JSON by default; it reaches validation as raw bytes/text and a model parameter fails with422. -
Any other type (
text/plain,application/x-www-form-urlencoded,application/octet-stream) is not parsed as JSON either, whatever the setting.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
title: str
year: int
@app.post("/books", status_code=201)
def create_book(book: Book) -> Book:
return book
c = TestClient(app)
payload = '{"title": "Dune", "year": 1965}'
for label, headers in [
("application/json", {"Content-Type": "application/json"}),
("application/json; charset=utf-8", {"Content-Type": "application/json; charset=utf-8"}),
("application/vnd.api+json", {"Content-Type": "application/vnd.api+json"}),
("missing", {}),
("text/plain", {"Content-Type": "text/plain"}),
]:
r = c.post("/books", content=payload, headers=headers)
print(label, "->", r.status_code, r.json() if r.status_code != 422 else r.json()["detail"])
The output:
application/json -> 201 {'title': 'Dune', 'year': 1965}
application/json; charset=utf-8 -> 201 {'title': 'Dune', 'year': 1965}
application/vnd.api+json -> 201 {'title': 'Dune', 'year': 1965}
missing -> 422 [{'type': 'model_attributes_type', 'loc': ['body'], 'msg': 'Input should be a valid dictionary or object to extract fields from', 'input': '{"title": "Dune", "year": 1965}'}]
text/plain -> 422 [{'type': 'model_attributes_type', ...}]
The error type is model_attributes_type: the body was handed to the Book model as a string, not as an object.
The CSRF Scenario This Prevents
A browser sends a cross-origin request without a CORS preflight only if it is a "simple" request: no credentials
attached, and no header that makes it non-simple. Content-Type: application/json is such a header, so a page on
another origin cannot send a JSON body to your API without the browser first asking permission. But a script can call
fetch() with a Blob body and no content type at all, and the browser sends it with no preflight. Before 0.132.0
FastAPI parsed that body as JSON anyway, so the missing header did not stop the request from being processed.
The official documentation describes the attack that remains realistic: an application with no authentication that
trusts everyone who can reach it, typically a tool run locally on localhost or on an internal network (the
documentation’s example is a local AI-agent API with a front end on the same host). A malicious website the user
happens to visit sends a header-less fetch() to http://localhost:8000/…;; the browser lets it through, and the
unauthenticated local API acts on the user’s behalf. Requiring a JSON content type makes such a request fail
validation instead.
An API exposed on the open internet is normally authenticated, and an attacker there can simply script requests; the documentation states that this specific protection matters mainly for the local and internal-network case. Strict checking is a safety net that costs nothing for well-behaved clients, not a replacement for authentication, CORS configuration (see Middleware, CORS & Proxies) or the authorization covered in Security Fundamentals.
Allowing Requests Without a Content-Type
Some existing clients, such as hand-written scripts, devices or curl -d calls with the header removed, never send
Content-Type. To keep serving them, set strict_content_type=False. The parameter exists on FastAPI(…) and on
APIRouter(…) (and on APIRouter.add_api_route()), and the router’s setting wins over the application’s for its routes.
The decorators such as @app.post and the include_router() methods do not take it; this was checked against 0.142.2 by
inspecting the signatures.
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
payload = '{"title": "Dune", "year": 1965}'
class Book(BaseModel):
title: str
year: int
# 1. application-wide
lenient = FastAPI(strict_content_type=False)
@lenient.post("/books")
def create(book: Book) -> Book:
return book
# 2. only one router
app = FastAPI()
legacy = APIRouter(prefix="/legacy", strict_content_type=False)
@legacy.post("/books")
def legacy_create(book: Book) -> Book:
return book
@app.post("/books")
def strict_create(book: Book) -> Book:
return book
app.include_router(legacy)
for name, a, path in [("app False", lenient, "/books"), ("router False", app, "/legacy/books"),
("default", app, "/books")]:
r = TestClient(a).post(path, content=payload)
print(name, r.status_code)
# the CSRF scenario: a "simple" cross-site request has no custom Content-Type
r = TestClient(app).post("/books", content=payload, headers={"Content-Type": "text/plain"})
print("text/plain", r.status_code, r.json()["detail"][0]["type"])
The output is app False 200, router False 200, default 422 and text/plain 422 model_attributes_type. Two things
to notice:
-
With checking relaxed, a request with no header is parsed as JSON again, the behaviour of FastAPI before 0.132.0.
-
Relaxing it does not make a wrong type acceptable: a body labelled
text/plainstill fails. Clients must either send no header at all or a JSON type.
Because the scope can be a single router, the safest way to keep old clients working is to put only their endpoints on a
router with strict_content_type=False and leave the rest strict, as the /legacy/books route above does.
Calling From the Command Line
curl -d '\{…}' sends Content-Type: application/x-www-form-urlencoded unless you override it, so a JSON body
sent that way is not parsed as JSON under any setting (it fails with 422, confirmed in a run with that header).
Always say it is JSON:
curl -X POST http://127.0.0.1:8000/books \
-H 'Content-Type: application/json' \
-d '{"title": "Dune", "year": 1965}'
# HTTPie sets the header itself for JSON fields
http POST :8000/books title=Dune year:=1965
Custom Request and APIRoute Classes
For logic that must see the raw request before FastAPI processes it, FastAPI lets you replace the Request class and
the route class. The documentation calls this an advanced feature and offers it as an alternative to middleware, with
three typical uses: converting non-JSON bodies (for example MessagePack) to JSON, decompressing gzip request bodies, and
logging request bodies. The mechanism has two parts:
-
A
Requestsubclass overridingbody()(or other methods). -
An
APIRoutesubclass overridingget_route_handler(), which returns the function that receives the request and produces the response. Wrapping it lets you swap in yourRequestsubclass, or catch exceptions around it.
Decompressing Gzip Request Bodies
import gzip
from collections.abc import Callable
from typing import Annotated
from fastapi import Body, FastAPI, Request, Response
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
class GzipRequest(Request):
async def body(self) -> bytes:
if not hasattr(self, "_body"):
body = await super().body()
if "gzip" in self.headers.getlist("Content-Encoding"):
body = gzip.decompress(body)
self._body = body
return self._body
class GzipRoute(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
request = GzipRequest(request.scope, request.receive)
return await original_route_handler(request)
return custom_route_handler
app = FastAPI()
app.router.route_class = GzipRoute
@app.post("/ratings/sum")
async def sum_ratings(ratings: Annotated[list[int], Body()]) -> dict:
return {"sum": sum(ratings), "count": len(ratings)}
c = TestClient(app)
print(c.post("/ratings/sum", json=[5, 4, 3]).json())
packed = gzip.compress(b"[5, 4, 3, 5]")
print(c.post("/ratings/sum", content=packed,
headers={"Content-Type": "application/json", "Content-Encoding": "gzip"}).json())
The first call posts plain JSON, the second a gzip-compressed body with Content-Encoding: gzip; both succeed
({'sum': 12, 'count': 3} and {'sum': 17, 'count': 4}). The route class is installed with
app.router.route_class = GzipRoute, which affects routes declared after the assignment. The Request is rebuilt
from the ASGI scope and receive callables, the two things every Request is made of, so the rest of FastAPI sees an
ordinary request whose body() is already decompressed. The hasattr(self, "_body") check keeps the caching
behaviour of the base class so the body is decompressed once.
The documentation labels this a toy example. Mind the limits: it trusts the client’s Content-Encoding, and
decompressing a request body without a size cap lets a small upload expand into a huge one (a decompression bomb), so cap
the decompressed size, for example by decompressing incrementally with zlib.decompressobj(…).decompress(data,
max_length). For compressed responses, use the provided gzip middleware instead
(Middleware, CORS & Proxies).
Reading the Body in an Exception Handler
A global exception handler receives the exception and the request, but the body has usually been consumed by then. A
custom route class can catch RequestValidationError around the original handler, where request.body() still works
because the body is cached:
from collections.abc import Callable
from typing import Annotated
from fastapi import APIRouter, Body, FastAPI, HTTPException, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
class ValidationErrorLoggingRoute(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
try:
return await original_route_handler(request)
except RequestValidationError as exc:
body = await request.body()
detail = {"errors": exc.errors(), "body": body.decode()}
raise HTTPException(status_code=422, detail=detail)
return custom_route_handler
app = FastAPI()
router = APIRouter(route_class=ValidationErrorLoggingRoute)
@router.post("/ratings/sum")
async def sum_ratings(ratings: Annotated[list[int], Body()]) -> int:
return sum(ratings)
app.include_router(router)
c = TestClient(app)
r = c.post("/ratings/sum", content='["a", 2]', headers={"Content-Type": "application/json"})
print(r.status_code)
import json; print(json.dumps(r.json(), indent=2))
The response is a 422 whose detail carries both the validation errors and the offending body:
{
"detail": {
"errors": [
{
"type": "int_parsing",
"loc": ["body", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "a"
}
],
"body": "[\"a\", 2]"
}
}
The route class is attached to one router with APIRouter(route_class=…) here, which is the way to limit the
change to some endpoints. The official page notes that for this particular need an exception handler for
RequestValidationError is usually simpler, because that exception handler’s request can also read the body; see
Status Codes & Error Handling for handlers and why
echoing a request body back to the client can leak data. Echoing it into a log is also sensitive when it can contain
passwords or tokens.
Choosing the Right Tool
| Need | Use |
|---|---|
Client address, URL, a header FastAPI does not model |
|
A header or cookie with validation and documentation |
|
Values shared by middleware, dependencies and handlers |
|
A body larger than memory, or an upload size cap |
|
An arbitrary body that must appear in OpenAPI |
|
JSON from a client that sends no |
|
Decode or transform every body before validation |
custom |
Behaviour for every request, including non-API paths |
middleware, see Middleware, CORS & Proxies |
Where Older Guides Are Outdated, and What Changed Recently
-
Missing
Content-Typeused to work. Guides and snippets from before 0.132.0 (2026-02-23) post JSON fromcurl -d,fetch()with aBlob, orrequests.post(data=json.dumps(…))without a content type and rely on FastAPI parsing it anyway. Today those requests fail with422unless the client sendsContent-Type: application/json(thejson=argument ofrequestsandhttpxdoes) or the app setsstrict_content_type=False. The change was marked breaking in the release notes. -
Request objects in older material. Examples written for early Starlette sometimes import from
starlette.requestsand readrequest.clientunguarded;request.clientmay beNone. -
Custom routes. The
APIRoutetechnique is unchanged; theget_route_handler()pattern shown here is the one in the current official page. FastAPI 0.137.x refactoredAPIRouterinternals (iter_route_contexts()); re-test custom route classes after upgrading, because they sit on an internal part of the router. -
Pin the version. FastAPI is still 0.x, so behaviour such as strict content-type checking may change in a minor release; see What Changed & Migration.
Pitfalls
-
Sending JSON without
Content-Type: application/jsonyields422withmodel_attributes_typeand aninputthat is the raw string; the cure is the header, not the model. -
curl -ddefaults toapplication/x-www-form-urlencoded, which is never parsed as JSON. -
strict_content_type=Falseremoves a defence on applications that trust their network; leave it on unless a client forces you, and scope it to one router. -
strict_content_typeis not a parameter of the route decorators (@app.post); set it on the app or the router. -
Reading the body through
Requestalone gives no validation and no OpenAPIrequestBody. -
request.stream()can be iterated once; callbody()first if you need the bytes again. -
request.clientcan beNone, and behind a proxy it is the proxy unless forwarded headers are trusted explicitly. -
A size check on
Content-Lengthalone is bypassable; count bytes while streaming and set the proxy limit too. -
A custom
Request.body()must keep the_bodycache, or the body is decompressed or re-read repeatedly. -
app.router.route_class = …applies only to routes declared after it; routers included later need their ownroute_class(or the setting on the router before the routes are added). -
Logging or echoing request bodies can leak passwords and tokens; redact before writing them.