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

request.client

An address object with host and port, or None when the server cannot determine it (for example some Unix-socket setups). Always guard against None, as the example does. Behind a reverse proxy this is the proxy’s address; see Middleware, CORS & Proxies for forwarded headers.

request.method

The HTTP method as an upper-case string.

request.url

The full URL. It has path, query, scheme, hostname and so on, and str(request.url) gives the whole URL.

request.base_url

The URL of the application root, including any mount path.

request.headers

A case-insensitive, immutable mapping; get takes lower-case or any-case names. getlist returns every value of a repeated header.

request.query_params

The raw, unvalidated query string as a multi-dict.

request.path_params

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.

request.cookies

The parsed Cookie header as a plain dictionary.

request.url_for(name, **path_params)

Builds an absolute URL for a route by its function name (or name=), which is how links in a response stay correct under a proxy prefix or a mount.

request.app

The application instance handling the request.

request.scope

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"]))

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 needs python-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, any application/*+json type (such as application/vnd.api+json), and parameters such as ; charset=utf-8 are parsed as JSON.

  • A body with no Content-Type header is not parsed as JSON by default; it reaches validation as raw bytes/text and a model parameter fails with 422.

  • 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/plain still 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 Request subclass overriding body() (or other methods).

  • An APIRoute subclass overriding get_route_handler(), which returns the function that receives the request and produces the response. Wrapping it lets you swap in your Request subclass, 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

Request parameter

A header or cookie with validation and documentation

Header() / Cookie(); see Headers & Cookies

Values shared by middleware, dependencies and handlers

request.state

A body larger than memory, or an upload size cap

request.stream(), plus a proxy limit

An arbitrary body that must appear in OpenAPI

Annotated[bytes, Body()] or a model, not a bare Request

JSON from a client that sends no Content-Type

strict_content_type=False, scoped to a router

Decode or transform every body before validation

custom Request + APIRoute

Behaviour for every request, including non-API paths

middleware, see Middleware, CORS & Proxies

Where Older Guides Are Outdated, and What Changed Recently

  • Missing Content-Type used to work. Guides and snippets from before 0.132.0 (2026-02-23) post JSON from curl -d, fetch() with a Blob, or requests.post(data=json.dumps(…​)) without a content type and rely on FastAPI parsing it anyway. Today those requests fail with 422 unless the client sends Content-Type: application/json (the json= argument of requests and httpx does) or the app sets strict_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.requests and read request.client unguarded; request.client may be None.

  • Custom routes. The APIRoute technique is unchanged; the get_route_handler() pattern shown here is the one in the current official page. FastAPI 0.137.x refactored APIRouter internals (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/json yields 422 with model_attributes_type and an input that is the raw string; the cure is the header, not the model.

  • curl -d defaults to application/x-www-form-urlencoded, which is never parsed as JSON.

  • strict_content_type=False removes a defence on applications that trust their network; leave it on unless a client forces you, and scope it to one router.

  • strict_content_type is not a parameter of the route decorators (@app.post); set it on the app or the router.

  • Reading the body through Request alone gives no validation and no OpenAPI requestBody.

  • request.stream() can be iterated once; call body() first if you need the bytes again.

  • request.client can be None, and behind a proxy it is the proxy unless forwarded headers are trusted explicitly.

  • A size check on Content-Length alone is bypassable; count bytes while streaming and set the proxy limit too.

  • A custom Request.body() must keep the _body cache, 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 own route_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.