Request Data
|
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 Flask 3.1.3 (released 2026-02-18) and Werkzeug 3.1.9 (released 2026-09-27), checked on
2026-10-07. Flask 3.2 and Werkzeug 3.2 were not released on that date, so everything described as "3.2" is upcoming. The
code uses Python 3.10+ syntax and was executed on Python 3.13 with app.test_client(). Every web application is a machine
for turning bytes sent by strangers into decisions, and the request object is where those bytes arrive. This page covers
each place the data can be (query string, form, files, JSON, raw body, headers, cookies), how Werkzeug parses it, which
value types you get back, what Flask limits by default, and the habits that keep untrusted input from becoming a bug. The
scenario is the Bookshelf catalogue that the other pages of this section build on.
How Flask Exposes the Request
flask.request is not a request. It is a LocalProxy that forwards every attribute access to the request object of the
request currently being handled, found through a context variable (the mechanism is explained in
Application and Request Lifecycle). The real object is an
instance of flask.Request, which is a thin subclass of Werkzeug’s werkzeug.wrappers.Request, which in turn extends the
I/O-free werkzeug.sansio.request.Request:
| Class | What it adds |
|---|---|
|
Everything that needs only headers and a URL, no body: |
|
Everything that reads the WSGI input stream: |
|
The match result ( |
Three properties of this design explain most of what follows:
-
Parsing is lazy and cached. Nothing is parsed until you touch it. The first access to
request.formparses the body, the first access torequest.argsparses the query string, and the result is stored on the request, so the second access is free and returns the same object. -
Everything is read-only.
args,form,files,cookiesandheadersare immutable. Assigning to them raisesTypeError, and a request that must be "changed" is changed by wrapping the WSGI application or subclassing the request class, not by mutating the object. -
The body can be consumed once. The WSGI input stream is a one-way pipe. Form parsing,
get_data()andget_json()cooperate by caching, but readingrequest.streamyourself and then asking forrequest.formgives you nothing.
from flask import Flask, request
from flask.wrappers import Request as FlaskRequest
from werkzeug.sansio.request import Request as SansIORequest
from werkzeug.wrappers import Request as WerkzeugRequest
assert FlaskRequest.__mro__[1] is WerkzeugRequest
assert WerkzeugRequest.__mro__[1] is SansIORequest
app = Flask(__name__)
@app.get("/probe")
def probe():
real = request._get_current_object()
return {
"proxy_class": request.__class__.__name__,
"real_class": type(real).__name__,
"same_object_twice": request.args is request.args,
"args_type": type(request.args).__name__,
}
with app.test_client() as client:
print(client.get("/probe?q=dune").get_json())
Source: Flask API: flask.Request
Which attribute holds the data depends on how the client sent it. The decision is driven by the Content-Type header, and
only the query string is independent of it:
request.files for file parts"] C -->|"application/json or application/*+json"| J["request.get_json() and request.json
raw bytes in request.get_data()"] C -->|"anything else, or no body"| D["request.data and request.get_data()
raw bytes, no parsing"] F --> V["request.values
args and form combined"] A --> V
The figure has one non-obvious consequence: a single request has exactly one body, so a handler that calls get_json() and
request.files on the same request is reading two things that cannot both exist. A JSON body and a file upload need either
two requests or a multipart/form-data body whose text fields carry the metadata.
Query String: request.args
request.args is the parsed query string, the part of the URL after the ?. It is a MultiDict (specifically an
ImmutableMultiDict), not a plain dictionary, because HTTP allows the same key to appear more than once
(?tag=sf&tag=classic). The class is explained in MultiDict, getlist and Type Conversion. Keys that appear without a value (?q=) are kept, with the
empty string as value.
The two read methods you use most are get() and getlist():
from flask import Flask, jsonify, request
app = Flask(__name__)
BOOKS = [
{"id": 1, "title": "Dune", "tags": ["sf", "classic"]},
{"id": 2, "title": "Dune Messiah", "tags": ["sf"]},
{"id": 3, "title": "The Left Hand of Darkness", "tags": ["sf", "classic"]},
{"id": 4, "title": "A Wizard of Earthsea", "tags": ["fantasy", "classic"]},
]
MAX_PER_PAGE = 50
@app.get("/books")
def list_books():
q = request.args.get("q", "").strip().lower()
tags = request.args.getlist("tag")
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 2, type=int), MAX_PER_PAGE)
found = [
b for b in BOOKS
if q in b["title"].lower() and all(t in b["tags"] for t in tags)
]
start = (max(page, 1) - 1) * max(per_page, 1)
return jsonify(total=len(found), page=page, items=found[start:start + per_page])
client = app.test_client()
print(client.get("/books?q=dune&tag=sf&tag=classic").get_json())
print(client.get("/books?page=2").get_json()["items"])
curl 'http://localhost:5000/books?q=dune&tag=sf&tag=classic'
{"items":[{"id":1,"tags":["sf","classic"],"title":"Dune"}],"page":1,"total":1}
The behaviours worth knowing, each checked against Werkzeug 3.1.9:
-
get(key, default, type=...)returns the first value of the key. If the key is missing the default is returned. IftyperaisesValueErrororTypeErrorthe default is returned too, so?page=abcand a missingpageare indistinguishable. Convenient, and a trap when "invalid" should be an error rather than "use the default" (see [strict-ints]). -
getlist(key, type=...)returns every value as a list. A missing key gives[], and values that failtypeare silently dropped from the list. -
args["q"]returns the first value, or raisesBadRequestKeyError, anHTTPExceptionthat Flask turns into a 400 response. Use it for parameters that are truly mandatory, andget()for optional ones. -
Percent-encoding and encoding. Parsing uses UTF-8, and since Werkzeug 2.3 invalid bytes stay percent-encoded instead of being replaced.
-
Type conversion is
type=callable, not annotations. Any callable that takes a string works:int,float, aDecimal,datetime.date.fromisoformat, or your own function that raisesValueErroron bad input.
Python’s int() accepts more than a visitor expects: surrounding whitespace, a leading sign, underscores ("1_000") and
non-ASCII digits. For a page number that is harmless, but when a value is used as an identifier, a quantity or a size, check
the shape first. The helper below also distinguishes "absent" from "invalid", clamps to a range and answers 400 with a
useful message:
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
def int_arg(name: str, default: int, *, minimum: int = 1, maximum: int = 100) -> int:
"""Read an integer query parameter strictly."""
raw = request.args.get(name)
if raw is None or raw == "":
return default
if not (raw.isascii() and raw.isdecimal()):
abort(400, description=f"{name} must be a non-negative integer")
value = int(raw)
if not minimum <= value <= maximum:
abort(400, description=f"{name} must be between {minimum} and {maximum}")
return value
@app.get("/books")
def books():
return jsonify(page=int_arg("page", 1, maximum=10_000), per_page=int_arg("per_page", 20))
client = app.test_client()
print(client.get("/books").get_json())
print(client.get("/books?per_page=500").status_code)
print(client.get("/books?page=abc").status_code)
print(client.get("/books?page=%2B1").status_code)
Source: Werkzeug: MultiDict
Query strings are also where the values that end up in logs, redirects and SQL come from. Anything read from args is a
string chosen by the client: validate it, and never format it into SQL or into a redirect target (use bound parameters, as
the Flask-SQLAlchemy page shows, and an allow-list or url_for for redirects).
Form Data: request.form and request.values
An HTML <form method="post"> sends its fields in the body. With the default enctype the Content-Type is
application/x-www-form-urlencoded; with enctype="multipart/form-data" (required when the form has a file input) it is
multipart/form-data. In both cases Werkzeug parses the body into request.form, an ImmutableMultiDict of text fields,
and the same get() / getlist() / type= rules as args apply.
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
CATALOGUE: list[dict] = []
@app.post("/books/new")
def create_book_from_form():
title = request.form.get("title", "").strip()
author = request.form.get("author", "").strip()
year = request.form.get("year", type=int)
tags = request.form.getlist("tag")
if not title or not author:
abort(400, description="title and author are required")
book = {"title": title, "author": author, "year": year, "tags": tags}
CATALOGUE.append(book)
return jsonify(book), 201
client = app.test_client()
resp = client.post(
"/books/new",
data={"title": "Dune", "author": "Frank Herbert", "year": "1965", "tag": ["sf", "classic"]},
)
print(resp.status_code, resp.get_json())
print(client.post("/books/new", data={"title": "No author"}).status_code)
Source: Flask: Testing, Form Data
curl -X POST http://localhost:5000/books/new \
-d 'title=Dune' -d 'author=Frank Herbert' -d 'year=1965' -d 'tag=sf' -d 'tag=classic'
request.values is a CombinedMultiDict that looks in args first and then in form. For GET requests it contains
only args (since Werkzeug 2.0), so a request body can no longer "invisibly" change what a cached GET response means.
It is a convenience for endpoints that deliberately accept the same parameter in the URL or the body, and a footgun
anywhere else: a value you expected from the form can be overridden by a query-string parameter with the same name,
because args wins. Prefer args or form explicitly.
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/echo", methods=["GET", "POST"])
def echo():
return jsonify(
values=request.values.to_dict(flat=False),
form=request.form.to_dict(flat=False),
kind=type(request.values).__name__,
)
client = app.test_client()
print(client.post("/echo?title=from-url", data={"title": "from-form", "author": "Ada"}).get_json())
print(client.get("/echo?title=from-url", data={"title": "ignored"}).get_json())
Source: Werkzeug: Request
Three details of form handling cause most support questions:
-
formis empty for other content types. If the client sendsapplication/json,text/plainor anything else,request.formis empty and the bytes are inrequest.dataorrequest.get_json(). A common symptom is a form handler that works from the browser and returns nothing for a JavaScriptfetch()that sent JSON. -
A file input needs
enctype="multipart/form-data". Without it the browser sends only the file name, as a text field, andrequest.filesstays empty. In debug mode Flask replacesrequest.fileswith a dictionary that raises an explanatoryDebugFilesKeyErrorfor exactly this mistake. Outside debug mode you just see aBadRequestKeyError. -
Parsing is guarded by limits.
MAX_CONTENT_LENGTH,MAX_FORM_MEMORY_SIZEandMAX_FORM_PARTSapply while the body is parsed. See Limits on Request Size.
A form handler is also the place where cross-site request forgery matters, because browsers send cookies with a forged
cross-site form submission. Flask has no built-in CSRF protection; the Flask-WTF page of this section shows
CSRFProtect and the hidden token, and the security page of the official documentation lists the options.
Uploaded Files: request.files
File parts of a multipart/form-data body end up in request.files, another ImmutableMultiDict, whose values are
werkzeug.datastructures.FileStorage objects. Only requests with the methods POST, PUT or PATCH and the multipart
content type fill it. The object behaves like a file (read(), seek(), stream) and has a few extra attributes:
| Attribute | Meaning and caveat |
|---|---|
|
The name the client claims. It is attacker-controlled: it may contain |
|
The form field name ( |
|
The type the client claims for the part. It is not verified. Check the real content (magic bytes, an image library) before trusting it. |
|
The size the part declared in its own header, which is usually |
|
The underlying file-like object. Small parts are held in memory and larger ones in a temporary file on disk. |
|
Writes the part to a path or a file object. The destination must be built from a name you chose, or from
|
import io
from flask import Flask, abort, jsonify, request
from werkzeug.utils import secure_filename
app = Flask(__name__)
ALLOWED = {".png", ".jpg", ".jpeg"}
@app.post("/books/<int:book_id>/cover")
def upload_cover(book_id: int):
cover = request.files.get("cover")
if cover is None or not cover.filename:
abort(400, description="choose a cover image")
safe_name = secure_filename(cover.filename)
suffix = "." + safe_name.rsplit(".", 1)[-1].lower() if "." in safe_name else ""
if suffix not in ALLOWED:
abort(415, description="covers must be PNG or JPEG")
head = cover.stream.read(8) # a real service would sniff the magic bytes here
return jsonify(
book=book_id,
stored_as=f"cover-{book_id}{suffix}",
claimed_name=cover.filename,
safe_name=safe_name,
claimed_type=cover.mimetype,
first_bytes=len(head),
title=request.form.get("caption", ""),
), 201
client = app.test_client()
resp = client.post(
"/books/7/cover",
data={"caption": "First edition", "cover": (io.BytesIO(b"\x89PNG\r\n\x1a\n" + b"x" * 100), "../../etc/Dune cover.PNG")},
)
print(resp.status_code, resp.get_json())
print(client.post("/books/7/cover", data={"caption": "no file"}).status_code)
Source: Flask: Uploading Files
This is only the request side: how the parts are parsed and what can be trusted. Where to store the file, how to check its type and size, how to serve it back and what to hand to an image-processing pipeline belong to the file uploads page of this section, and the media pipeline itself to Media Optimization.
JSON Bodies: get_json() and request.json
An API client sends Content-Type: application/json and a JSON document. The two ways to read it are the method
request.get_json(force=False, silent=False, cache=True) and the property request.json, which is get_json() with the
default arguments. The decisions get_json() makes, in order:
-
If the media type is not
application/json(orapplication/*+json, such asapplication/vnd.api+json) andforceis false, raise 415 Unsupported Media Type. -
Otherwise read the body (cached) and parse it with the JSON provider’s
loads. -
If parsing fails, raise 400 Bad Request.
-
With
silent=True, both failures returnNoneinstead of raising. -
With
force=True, the media type check is skipped and the body is parsed whatever the header says.
| Situation | Result | Notes |
|---|---|---|
|
the parsed value |
any JSON type: object, array, string, number, |
Missing or different |
415 |
since Werkzeug 2.3 (2.1 and 2.2 answered 400, and Flask 1.x returned |
Correct |
400 |
the decoding message is replaced by a generic text unless the app is in debug mode |
Correct |
400 |
an empty string is not valid JSON |
Any failure with |
|
indistinguishable from the JSON value |
Any content type with |
parsed, or 400 |
use only for clients you cannot fix, such as webhooks that send JSON as |
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
BOOKS: dict[int, dict] = {}
def validate_book(payload) -> tuple[dict, list[str]]:
errors: list[str] = []
if not isinstance(payload, dict):
return {}, ["the body must be a JSON object"]
title = payload.get("title")
pages = payload.get("pages")
if not isinstance(title, str) or not title.strip():
errors.append("title must be a non-empty string")
if pages is not None and (not isinstance(pages, int) or isinstance(pages, bool) or pages < 1):
errors.append("pages must be a positive integer")
# copy only the fields we know: never pass the whole payload to a model
return {"title": (title or "").strip(), "pages": pages}, errors
@app.post("/api/books")
def create_book():
payload = request.get_json()
book, errors = validate_book(payload)
if errors:
return jsonify(errors=errors), 422
book["id"] = len(BOOKS) + 1
BOOKS[book["id"]] = book
return jsonify(book), 201
client = app.test_client()
print(client.post("/api/books", json={"title": "Dune", "pages": 412, "is_admin": True}).get_json())
print(client.post("/api/books", data="title=Dune").status_code)
print(client.post("/api/books", data="{broken", content_type="application/json").status_code)
print(client.post("/api/books", json=["Dune"]).get_json())
print(client.post("/api/books", json={"title": "", "pages": -3}).get_json())
Source: Flask API: Request.get_json
curl -X POST http://localhost:5000/api/books \
-H 'Content-Type: application/json' \
-d '{"title": "Dune", "pages": 412}'
{"id":1,"pages":412,"title":"Dune"}
The example shows four habits that the books' short handlers skip:
-
Check the type of the decoded value.
get_json()returns whatever the client sent, and a handler that doesrequest.get_json().get("name")fails withAttributeError(a 500) on a JSON array, a string, a number ornull. Full-Stack Flask and React writes its handlers that way and also mixesrequest.get_json()withrequest.files.get()in the same view, which cannot work for one request as explained after the figure above. -
Whitelist fields.
is_adminin the payload was ignored because onlytitleandpagesare copied. Passing**payloadto a model constructor is mass assignment and lets clients set fields you never meant to expose. -
Validate types, not just presence.
Trueis anintin Python, so a check onpageshas to excludeboolexplicitly. A schema library does this for you, and the building REST APIs page of this section shows marshmallow and Pydantic taking over this job. -
Answer with a consistent error body. The default 400 and 415 responses are HTML pages. A Custom Request Class shows how to turn them into JSON for an API.
cache=True stores the parsed value on the request, so calling get_json() twice parses once. silent=True has its own
cache slot, so a silent call after a failing normal call does not re-raise. is_json is a property that tells you whether
the media type looks like JSON without reading the body, which is useful for one view that serves both forms and JSON.
Parsing uses request.json_module, which is Flask’s flask.json module and therefore the application’s JSON provider
(app.json). Swapping the provider for orjson or customizing decoding is covered on the responses and JSON page of this
section, because the same provider serializes the responses.
Two limits apply to the JSON body: the size limit of Limits on Request Size, and the fact that the whole document is read into memory
before it is parsed. Deeply nested or very large numbers in a document of acceptable size can still be expensive, so put a
reasonable MAX_CONTENT_LENGTH in front of any endpoint that parses JSON from untrusted clients.
The Raw Body: data, get_data() and stream
Sometimes the unparsed bytes are what matters: a webhook whose signature covers the exact payload, a text/csv import, a
protobuf message, an upload sent as a bare application/octet-stream body.
| Access | Behaviour |
|---|---|
|
Reads the whole body. Cached by default, so later |
|
The raw bytes, but only if the body was not form data. For |
|
The WSGI input stream wrapped with safety checks: it stops at |
|
The raw |
import hashlib
import hmac
import secrets
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
# In production the secret comes from the environment or a secrets manager, never from source code.
app.config["ISBN_WEBHOOK_SECRET"] = secrets.token_hex(32)
@app.post("/webhooks/isbn-sync")
def isbn_sync():
request.max_content_length = 64 * 1024 # this endpoint never needs more than 64 KiB
secret = app.config.get("ISBN_WEBHOOK_SECRET")
if not secret:
abort(503, description="webhook secret is not configured")
body = request.get_data() # the exact bytes the sender signed
sent = request.headers.get("X-Signature-256", "")
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sent, expected):
abort(401)
payload = request.get_json(force=True) # reuses the cached bytes
return jsonify(accepted=len(payload.get("isbns", [])))
client = app.test_client()
body = b'{"isbns": ["9780441013593", "9780345339683"]}'
good = "sha256=" + hmac.new(app.config["ISBN_WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
print(client.post("/webhooks/isbn-sync", data=body, headers={"X-Signature-256": good}).get_json())
print(client.post("/webhooks/isbn-sync", data=body, headers={"X-Signature-256": "sha256=00"}).status_code)
print(client.post("/webhooks/isbn-sync", data=body + b" ", headers={"X-Signature-256": good}).status_code)
Source: Werkzeug: Request.get_data
The signature is computed over the bytes, so the check must run before anything re-serializes the payload (json.dumps of
the parsed document produces different bytes). The comparison uses hmac.compare_digest so its running time does not leak
how many leading characters matched. The same endpoint reads the JSON with force=True because some webhook senders use
text/plain.
Headers: request.headers and the Parsed Accessors
request.headers is a case-insensitive, read-only mapping (EnvironHeaders) built from the WSGI environment. get()
returns None or your default for a missing header, getlist() returns every value of a repeated header, and in works.
Subscripting a missing header raises KeyError, which is not an HTTP error: an unhandled one becomes a 500 response,
whereas a missing key in args, form or files raises BadRequestKeyError and becomes a 400. Use get() for
anything the client controls.
Werkzeug also parses the headers that have structure, so you rarely need to split strings by hand:
| Accessor | What you get |
|---|---|
|
The raw header, the media type without parameters ( |
|
The declared length as an |
|
Parsed |
|
An |
|
Conditional and range request headers, used by |
|
An object whose |
|
|
import base64
import re
from flask import Flask, jsonify, request
app = Flask(__name__)
REQUEST_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
@app.get("/api/ping")
def ping():
rid = request.headers.get("X-Request-ID", "")
if not REQUEST_ID.fullmatch(rid):
rid = "generated-by-server" # never echo an unchecked header into logs or responses
auth = request.authorization
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
return jsonify(
request_id=rid,
auth_type=auth.type if auth else None,
user=auth.username if auth and auth.type == "basic" else None,
token_present=bool(auth and auth.type == "bearer" and auth.token),
negotiated=best,
language=request.accept_languages.best_match(["es", "en"]),
content_type=request.content_type,
)
client = app.test_client()
basic = "Basic " + base64.b64encode(b"ada:not-a-real-password").decode()
print(client.get("/api/ping", headers={
"X-Request-ID": "abc-123", "Authorization": basic,
"Accept": "text/html;q=0.5, application/json", "Accept-Language": "es-ES,es;q=0.9,en;q=0.5",
}).get_json())
print(client.get("/api/ping", headers={"Authorization": "Bearer tok", "X-Request-ID": "bad id!"}).get_json())
with app.test_request_context("/api/ping"):
for access in (lambda: request.headers["X-Missing"], lambda: request.args["q"]):
try:
access()
except KeyError as exc:
print(type(exc).__name__, getattr(exc, "code", None))
Source: Werkzeug: Headers
Two security notes belong with headers. Headers such as X-Forwarded-For, X-Forwarded-Proto, X-Requested-With,
Origin and User-Agent are written by the client unless a trusted proxy overwrites them, so none of them proves anything on
its own. And a header value you echo into a log line or a response can carry control characters (header injection and log
forging), which is why the example validates the request id with a strict pattern first.
The book idiom for detecting an Ajax call is the subject of Detecting an Ajax Request below.
Cookies: request.cookies
request.cookies is an ImmutableMultiDict of the cookies the browser sent, parsed from every Cookie header. Only names
and values arrive; the attributes (Path, Secure, HttpOnly, expiry) are properties of the Set-Cookie the server
sent earlier and are never sent back. Setting and deleting cookies is done on the response and is covered with
set_cookie on the responses page of this section, and the signed session cookie belongs to the sessions page.
from flask import Flask, jsonify, request
app = Flask(__name__)
THEMES = {"light", "dark"}
@app.get("/preferences")
def preferences():
theme = request.cookies.get("theme", "light")
if theme not in THEMES: # a cookie is client input like any other
theme = "light"
return jsonify(theme=theme, cookie_names=sorted(request.cookies))
client = app.test_client()
client.set_cookie("theme", "dark")
client.set_cookie("tour_done", "1")
print(client.get("/preferences").get_json())
print(app.test_client().get("/preferences", headers={"Cookie": "theme=<script>"}).get_json())
Source: Flask: Quickstart, Cookies
A cookie is plain text that the user can edit. Unless it is the Flask session cookie (signed with SECRET_KEY) or
you signed it yourself with ItsDangerous, treat the value as untrusted input exactly like a query parameter.
MultiDict, getlist and Type Conversion
args, form, files, cookies and values are Werkzeug multi-value mappings, which behave like a dictionary where the
"normal" access returns the first value and special methods return all of them:
| Operation | Result |
|---|---|
|
The first value, or |
|
The first value converted with |
|
A list of all values; |
|
A plain |
|
A |
|
Every |
|
|
|
A mutable |
from werkzeug.datastructures import ImmutableMultiDict
args = ImmutableMultiDict([("tag", "sf"), ("tag", "classic"), ("n", "5"), ("n", "x")])
assert args["tag"] == "sf"
assert args.getlist("tag") == ["sf", "classic"]
assert args.get("n", type=int) == 5
assert args.get("missing", 7, type=int) == 7 # missing -> default
assert args.getlist("n", type=int) == [5] # "x" silently dropped
assert dict(args) == {"tag": "sf", "n": "5"} # duplicates lost
assert args.to_dict(flat=False) == {"tag": ["sf", "classic"], "n": ["5", "x"]}
assert list(args.items(multi=True))[:2] == [("tag", "sf"), ("tag", "classic")]
try:
args.add("a", "b")
except TypeError as exc:
print("immutable:", exc)
editable = args.copy()
editable.add("tag", "fantasy")
print(type(editable).__name__, editable.getlist("tag"))
Source: Werkzeug: MultiDict
Which class is used is configurable. Request.parameter_storage_class (for args and form) and
Request.dict_storage_class (for cookies) default to ImmutableMultiDict. OrderedMultiDict was deprecated in
Werkzeug 3.1 because the base MultiDict already keeps insertion order, and Werkzeug 3.2 (unreleased) removes it.
Do not choose a plain dict class to "make it simpler": it remembers only the last value of a repeated key, which is
the root of parameter-pollution bugs.
The Request Line: Method, Path and URLs
The attributes below describe where the request came from and what it asked for. The example values are what a request to
http://books.example.com:8000/shelf/books/42?lang=es produces when the application is mounted under the script root
/shelf (for instance by a reverse proxy that strips the prefix and sets SCRIPT_NAME):
| Attribute | Value | Meaning |
|---|---|---|
|
|
Upper-cased HTTP method. |
|
|
Path after the script root, already percent-decoded. This is what routing matched. |
|
|
Path plus |
|
|
The raw bytes of the query string. |
|
|
The prefix the application is mounted at. |
|
|
The full URL, query string included. |
|
|
The URL without the query string. |
|
|
Scheme, host and script root. |
|
|
Scheme and host only. |
|
|
Host (with the port when not standard), validated against |
|
|
From the WSGI server, unless a proxy fix is installed. |
|
|
Flask’s routing result. All are |
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.get("/books/<int:book_id>")
def show_book(book_id: int):
return jsonify(
method=request.method, path=request.path, full_path=request.full_path,
query=request.query_string.decode(), script_root=request.script_root,
url=request.url, base_url=request.base_url, url_root=request.url_root,
host_url=request.host_url, host=request.host,
endpoint=request.endpoint, view_args=request.view_args, rule=str(request.url_rule),
)
client = app.test_client()
data = client.get("/books/42?lang=es", base_url="http://books.example.com:8000/shelf").get_json()
for key, value in data.items():
print(f"{key:12} {value}")
Source: Flask API: flask.Request
Prefer url_for() to build links and redirect targets. These attributes are for reading what arrived, for logging, and for
the rare case where a view needs its own URL.
Client Address, Scheme and Host Behind a Proxy
request.remote_addr is the address of whoever opened the TCP connection to the WSGI server. In production that is almost
never the browser: a load balancer, nginx or a platform router sits in front, so remote_addr is the proxy’s address,
request.scheme is http even though the visitor used HTTPS, and request.host is an internal name. Everything that
depends on those values then misbehaves: rate limits count one client for all traffic, url_for(_external=True) and
redirects point at http:// or at an internal host, and the Secure cookie logic sees an insecure request.
The proxy passes the original values in X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port
and X-Forwarded-Prefix headers. The headers cannot be trusted by default, because a client can send them too. Werkzeug’s
ProxyFix middleware rewrites the WSGI environment from those headers, but only as many entries as you tell it to trust:
| Parameter | Default | Meaning |
|---|---|---|
|
|
Number of proxies that append to |
|
|
Number of proxies that set |
|
|
Number of proxies that set |
|
|
Number of proxies that set |
|
|
Number of proxies that set |
from flask import Flask, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix
def build(app_name: str, trust_proxy: bool) -> Flask:
app = Flask(app_name)
@app.get("/whoami")
def whoami():
return jsonify(
remote_addr=request.remote_addr, scheme=request.scheme,
host=request.host, access_route=request.access_route,
)
if trust_proxy:
# exactly one proxy in front of the app: trust one hop of each header
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
return app
forwarded = {
# the client forged its own first entry; the trusted proxy appended the real peer
"X-Forwarded-For": "198.51.100.7, 203.0.113.9",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "books.example.com",
}
print("direct :", build("direct", False).test_client().get("/whoami", headers=forwarded).get_json())
print("proxied:", build("proxied", True).test_client().get("/whoami", headers=forwarded).get_json())
With one trusted proxy the application sees 203.0.113.9, the peer address the proxy observed, and not the forged
198.51.100.7 that the client put first. Without ProxyFix the headers are ignored and remote_addr stays 127.0.0.1.
Two warnings follow from that output:
-
Count the hops exactly. Too small a number leaves the proxy’s address in place. Too large a number reads an entry the client wrote, which is how rate limits and IP allow-lists get bypassed. Apply
ProxyFixonly when the app really is behind a proxy, and make the proxy overwrite (not append to) the headers coming from outside where it is the first hop. -
request.access_routeis not fixed up. It is derived from the rawX-Forwarded-Forheader, whose first entry is client-supplied, so it is useful for logging the whole chain and wrong as an identity. Useremote_addrafterProxyFix.
The Host header has its own control. TRUSTED_HOSTS (Flask 3.1) is a list of accepted host names; an entry starting with
a dot also matches the subdomains (and the bare domain). A request with another host is answered with 400 during routing,
before the view runs, although before_request and after_request callbacks still run. It protects every feature that
builds URLs from request.host, such as password-reset links and external url_for().
from flask import Flask, request
app = Flask(__name__)
app.config["TRUSTED_HOSTS"] = ["books.example.com", ".example.org"]
@app.get("/")
def index():
return request.host
client = app.test_client()
for host in ("books.example.com", "evil.test", "shop.example.org", "example.org"):
print(host, client.get("/", headers={"Host": host}).status_code)
Since Werkzeug 3.1.7, request.host also checks the characters of the value and an empty or invalid Host produces an
empty string and not an exception, which is another reason to configure TRUSTED_HOSTS and let the reverse proxy reject
unknown hosts too. The deployment page of this section shows the matching nginx and Gunicorn settings.
Limits on Request Size
Reading a request costs memory and time, and the client decides how much it sends. Flask has three limits, each a config
key and an attribute of the request that can be overridden for one view. All of them answer with
413 Request Entity Too Large (RequestEntityTooLarge) when exceeded.
| Config key and request attribute | Default | Since | Meaning |
|---|---|---|---|
|
|
0.6 (per request: 3.1) |
Maximum bytes read from the body, whatever its type. When it is |
|
|
3.1 |
Maximum size of any single non-file field in a |
|
|
3.1 |
Maximum number of fields and files in a |
Two details matter in practice. MAX_FORM_MEMORY_SIZE applies only to multipart bodies since Werkzeug 3.1.9, which stopped
applying it to application/x-www-form-urlencoded; there only MAX_CONTENT_LENGTH limits the size. And the default
MAX_CONTENT_LENGTH of None means Flask itself does not limit a request, which the official documentation attributes to
the WSGI server or the HTTP server being the better place for a global limit. Set it anyway for any application that accepts
bodies from the Internet.
import io
from flask import Flask, jsonify, request
from werkzeug.exceptions import RequestEntityTooLarge
app = Flask(__name__)
app.config.update(MAX_CONTENT_LENGTH=1024, MAX_FORM_MEMORY_SIZE=100, MAX_FORM_PARTS=3)
@app.post("/api/notes")
def notes():
return jsonify(size=len(request.get_data())) # raw bytes: only MAX_CONTENT_LENGTH applies
@app.post("/api/forms")
def forms():
return jsonify(fields=len(request.form), files=len(request.files)) # parses the body
@app.post("/books/<int:book_id>/cover")
def cover(book_id: int):
# this one view accepts large images; it must set the limits before touching the body
request.max_content_length = 8 * 1024 * 1024
request.max_form_memory_size = 10_000
return jsonify(fields=len(request.form), files=len(request.files))
@app.errorhandler(RequestEntityTooLarge)
def too_large(error):
return jsonify(error="request body too large"), 413
client = app.test_client()
print("body 500 bytes ", client.post("/api/notes", data=b"x" * 500).status_code)
print("body 2000 bytes ", client.post("/api/notes", data=b"x" * 2000).status_code)
print("urlencoded field ", client.post("/api/forms", data={"caption": "x" * 500}).status_code)
upload = {"caption": "x" * 500, "cover": (io.BytesIO(b"x" * 5000), "c.png")}
print("multipart, global", client.post("/api/forms", data=upload).status_code)
upload = {"caption": "x" * 500, "cover": (io.BytesIO(b"x" * 5000), "c.png")}
print("multipart, view ", client.post("/books/1/cover", data=upload).status_code)
four = {"a": "1", "b": "2", "c": "3", "d": "4", "f": (io.BytesIO(b"1"), "f.txt")}
print("five parts ", client.post("/api/forms", data=four).status_code)
The per-request attributes are set in the view (or in a before_request callback), and they have to be set before anything
reads the body, because the checks run while the stream is read. Setting request.max_content_length after request.form
was accessed has no effect on a body that is already parsed.
Three practical points complete the picture:
-
Defence in depth. Set the same limit in the front server (
client_max_body_sizein nginx,limit_request_lineand friends in Gunicorn) so oversized bodies are refused before they reach Python. Also set timeouts: a slow client that trickles bytes ties up a worker as effectively as a large one. -
The client may see a connection reset. If the limit is hit before the body is exhausted, the rest of the stream cannot be read safely, so depending on the server a client can get a reset instead of the 413 page.
-
Streaming uploads. To accept something larger than memory, read
request.streamin chunks and write them out as you go, withmax_content_lengthset to the true upper bound of that endpoint.
HTTP Method Overrides
HTML forms and some old proxies can only send GET and POST. A REST design that uses PUT, PATCH and DELETE then
needs a way to "tunnel" the real method. The Flask documentation’s pattern wraps the WSGI application so the override is
applied before routing, with a header such as X-HTTP-Method-Override. The version below is stricter than the documented
one: it honours the header only on a POST request, because a header on a GET must not be able to turn a safe,
cacheable request into a destructive one.
from flask import Flask, jsonify, request
class MethodOverrideMiddleware:
allowed_methods = frozenset({"PUT", "PATCH", "DELETE"})
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
if environ.get("REQUEST_METHOD") == "POST":
override = environ.get("HTTP_X_HTTP_METHOD_OVERRIDE", "").upper()
if override in self.allowed_methods:
environ["REQUEST_METHOD"] = override
return self.app(environ, start_response)
app = Flask(__name__)
app.wsgi_app = MethodOverrideMiddleware(app.wsgi_app)
@app.route("/api/books/<int:book_id>", methods=["GET", "POST", "DELETE"])
def book(book_id: int):
return jsonify(method=request.method, id=book_id)
client = app.test_client()
print(client.post("/api/books/3", headers={"X-HTTP-Method-Override": "DELETE"}).get_json())
print(client.get("/api/books/3", headers={"X-HTTP-Method-Override": "DELETE"}).get_json())
print(client.post("/api/books/3").get_json())
The override is a form of request smuggling if misused. It must stay behind the same authentication and CSRF protection as
the real method (a browser form that posts to a DELETE route is still a cross-site form), and it should be enabled only
when a client really cannot send the method. A modern fetch() or curl sends DELETE directly, so for an API the
middleware is rarely needed. For a server-rendered HTML form, the usual alternative is a dedicated POST route such as
/books/3/delete.
Request Content Checksums
Some APIs require the server to compute a hash of the body (an integrity check, or a signature that covers an uploaded file as well as its form fields). The difficulty is that JSON, form data and files are consumed by different code paths, and once Werkzeug has parsed the body the raw bytes are gone. The documented answer is to wrap the WSGI input stream before anything reads it, and update a hash as bytes flow through:
import hashlib
import io
from flask import Flask, jsonify, request
class ChecksumStream:
def __init__(self, stream):
self._stream = stream
self.digest = hashlib.sha256()
def read(self, size=-1):
chunk = self._stream.read(size)
self.digest.update(chunk)
return chunk
def readline(self, size=-1):
line = self._stream.readline(size)
self.digest.update(line)
return line
def start_checksum(req):
"""Wrap wsgi.input of this request. Call it before the body is read."""
stream = ChecksumStream(req.environ["wsgi.input"])
req.environ["wsgi.input"] = stream
return stream.digest
app = Flask(__name__)
@app.post("/api/imports")
def import_books():
digest = start_checksum(request)
form = dict(request.form) # parses form fields ...
uploaded = list(request.files) # ... and file parts
request.get_data(parse_form_data=True) # reads whatever is left (JSON, octet-stream)
return jsonify(sha256=digest.hexdigest(), fields=form, files=uploaded)
client = app.test_client()
resp = client.post("/api/imports", data={"note": "weekly", "csv": (io.BytesIO(b"title\nDune\n"), "books.csv")})
print(resp.get_json())
# a body of exactly known bytes gives a digest that can be verified independently
payload = b'{"isbns": ["9780441013593"]}'
resp = client.post("/api/imports", data=payload, content_type="application/octet-stream")
print(resp.get_json()["sha256"] == hashlib.sha256(payload).hexdigest())
Source: Flask: Request Content Checksums
The wrapper must be installed before the body is touched, so a before_request callback that reads request.form or
request.get_json() breaks it silently (the hash then covers nothing). For a payload that is read whole anyway, such as a
webhook, the request.get_data() approach in The Raw Body: data, get_data() and stream is simpler, and it is the right one when the signature covers a small
document.
A Custom Request Class
The class Flask uses is the attribute Flask.request_class. Subclassing flask.Request is the supported way to change how
every request behaves: replace on_json_loading_failed to return an API-friendly error, add a validated property, or
change parameter_storage_class. A subclass of the class that the app uses is also the way to apply a rule to a whole
application, such as a lower default body limit for an internal admin app.
import re
from functools import cached_property
from flask import Flask, Request, jsonify, request
from werkzeug.exceptions import BadRequest, HTTPException, UnsupportedMediaType
class BookshelfRequest(Request):
@cached_property
def request_id(self) -> str:
candidate = self.headers.get("X-Request-ID", "")
return candidate if re.fullmatch(r"[A-Za-z0-9._-]{1,64}", candidate) else "none"
def on_json_loading_failed(self, e):
# Flask's default hides the decoding detail outside debug mode; an API usually wants a short, safe reason.
if e is None:
raise UnsupportedMediaType("send the body as application/json")
raise BadRequest("the request body is not valid JSON")
app = Flask(__name__)
app.request_class = BookshelfRequest
@app.errorhandler(HTTPException)
def as_json(error):
return jsonify(error=error.name, detail=error.description, request_id=request.request_id), error.code
@app.post("/api/books")
def create():
return jsonify(received=request.get_json()), 201
client = app.test_client()
print(client.post("/api/books", json={"title": "Dune"}, headers={"X-Request-ID": "r-1"}).get_json())
print(client.post("/api/books", data="title=Dune").get_json())
print(client.post("/api/books", data="{oops", content_type="application/json").get_json())
Source: Flask API: Flask.request_class
The generic error handler here is only a sketch to show the effect of the override; error handling on its own page of this section covers handlers by exception class and code, JSON versus HTML negotiation and logging.
Sending Request Data in Tests
The test client builds a WSGI environment from keyword arguments, so each way of sending data above has a direct spelling (this is the same API the other examples on this page use):
| You want | Test client argument |
|---|---|
A query string |
|
A form body |
|
A JSON body |
|
A raw body |
|
Headers and cookies |
|
A different client address or scheme |
|
A request context without dispatching |
|
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.post("/books/search")
def search():
return jsonify(
q=request.args.get("q"), tags=request.args.getlist("tag"),
remote=request.remote_addr, secure=request.is_secure, body=request.get_json(silent=True),
)
client = app.test_client()
resp = client.post(
"/books/search",
query_string={"q": "dune", "tag": ["sf", "classic"]},
json={"limit": 5},
base_url="https://books.example.com/",
environ_overrides={"REMOTE_ADDR": "203.0.113.9"},
)
print(resp.get_json())
with app.test_request_context("/books/search?q=herbert", method="POST", json={"limit": 1}):
print(request.args["q"], request.get_json())
Detecting an Ajax Request
Older Flask code asks the request whether it came from XMLHttpRequest. request.is_xhr was removed from Werkzeug in 1.0
(2020), so code from Flask Framework Cookbook (2nd edition) that tests it fails with AttributeError on any current
version. The 3rd edition tests the X-Requested-With header instead, which works but is a convention that only some
libraries follow (jQuery sends it; fetch() does not unless you add it) and that any client can fake.
A better design avoids the question. Serve HTML and JSON from separate routes (/books for the page, /api/books for the
data), or negotiate on what the client asks for with request.accept_mimetypes.best_match(["application/json", "text/html"])
as in Headers: request.headers and the Parsed Accessors, or on request.is_json for the body. All of these state the intent of the request instead of guessing
at the technology that sent it.
Pitfalls and Security
-
Everything in the request is untrusted. That includes query values, form fields, JSON, headers (including
Host,Originand the forwarded headers), cookies other than the signed session, file names, content types and the declaredContent-Length. -
Validate and whitelist. Validate shape and type, convert explicitly, reject unknown or wrongly typed values with 4xx responses, and copy only the fields you intend to store. Do not pass
request.formor the JSON dictionary toModel(**data). -
Do not confuse
get()defaults with validation.get("page", 1, type=int)hides malformed input behind the default. -
Use
get()for optional values and[]for required ones.args["x"],form["x"]andfiles["x"]raise a 400 for you;headers["x"]raisesKeyErrorand yields 500. -
Check
get_json()results. They can be a list, a string, a number orNone, not only a dictionary. -
A body is read once. Read raw bytes with
get_data()(cached) before anything that may parse them, or wrap the stream as in Request Content Checksums. Never readrequest.streamand also expectformto work. -
Set size limits.
MAX_CONTENT_LENGTHat the app and at the proxy; per-view overrides for the few routes that accept more. KeepMAX_FORM_MEMORY_SIZEandMAX_FORM_PARTSat their defaults or lower unless you have a reason. -
Trust forwarded headers only through
ProxyFixwith the right hop counts, and setTRUSTED_HOSTSfor theHostheader. -
Do not log whole requests. Bodies and
AuthorizationorCookieheaders contain credentials; log the method, path, status and request id. -
The proxy outlives the request.
requestworks only while a request is being handled, in the same thread or greenlet that handled it. For a background thread, copy the values you need first, or usecopy_current_request_context, as explained in Application and Request Lifecycle. -
CSRF and CORS are not parsing. A request that parses perfectly can still be forged by another site. See the Flask-WTF page for CSRF tokens, and CORS for the site’s page on cross-origin policy.
What Changed and Where Older Material Is Outdated
| Version | Change that affects request handling |
|---|---|
Werkzeug 1.0 |
|
Werkzeug 2.0 |
|
Flask 2.2 |
The request and application contexts are backed by |
Werkzeug 2.3 |
|
Werkzeug 3.0 |
The |
Flask 3.1 and Werkzeug 3.1 |
|
Werkzeug 3.1.7 and 3.1.8 |
|
Werkzeug 3.1.9 |
|
Werkzeug 3.2 (unreleased) |
Requires Python 3.11 or later and removes |
Flask 3.2 (unreleased) |
|
Source: Flask: Changes
Where the Books Are Outdated
The Flask books use the request object in almost every recipe, and the basic calls (request.args.get, request.form.get,
request.get_json, request.files) are still right. The details below are what current versions do differently.
| In the book | Current Flask (3.1) |
|---|---|
|
Removed in Werkzeug 1.0. See Detecting an Ajax Request: check an explicit header, negotiate with |
A rule of thumb that GET reads |
True as a convention, but |
|
Fails with |
|
One request has one body. A JSON body cannot carry a file, and a multipart body cannot be read with |
|
Convert with |
No size limits in any of the books |
Set |
|
Behind a proxy it is the proxy’s address, so every visitor gets the same answer. Use |
Source: Werkzeug: Changes
Summary
-
flask.requestis a proxy to a WerkzeugRequestthat parses lazily and caches. Its data containers are immutable. -
Match the accessor to the content type:
argsfor the query string,formandfilesfor form and multipart bodies,get_json()for JSON,get_data()for raw bytes,headersandcookiesfor metadata. -
MultiDictkeeps repeated keys:get()and[]return the first value,getlist()returns all,type=converts, and a failed conversion silently becomes the default. -
get_json()answers 415 for the wrong content type and 400 for malformed JSON, and can return any JSON type, so validate. -
Set
MAX_CONTENT_LENGTHand keepMAX_FORM_MEMORY_SIZEandMAX_FORM_PARTSin place, override them per view only for the routes that need more, and set the same limits in the front server. -
Behind a proxy,
remote_addr,schemeandhostare only right afterProxyFixwith the exact hop counts, andTRUSTED_HOSTSguards theHostheader. -
Method overrides and request checksums are WSGI-level patterns that run before routing and before the body is read.