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

werkzeug.sansio.request.Request

Everything that needs only headers and a URL, no body: method, path, args, cookies, host, url, headers, accept_*, authorization, is_json, remote_addr, conditional and range headers.

werkzeug.wrappers.Request

Everything that reads the WSGI input stream: stream, data, get_data(), form, files, values, get_json() and the limits max_content_length, max_form_memory_size and max_form_parts.

flask.Request

The match result (url_rule, view_args, endpoint, blueprint, blueprints, routing_exception), the three limits read from app.config, and a JSON error policy that hides decoding details outside debug mode.

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.form parses the body, the first access to request.args parses 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, cookies and headers are immutable. Assigning to them raises TypeError, 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() and get_json() cooperate by caching, but reading request.stream yourself and then asking for request.form gives 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())

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:

flowchart TD U["Query string after the question mark"] --> A["request.args"] B["Request body"] --> C{"Content-Type"} C -->|"application/x-www-form-urlencoded"| F["request.form"] C -->|"multipart/form-data"| M["request.form for text fields
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
Figure 1. Where each part of a request ends up

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. If type raises ValueError or TypeError the default is returned too, so ?page=abc and a missing page are 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 fail type are silently dropped from the list.

  • args["q"] returns the first value, or raises BadRequestKeyError, an HTTPException that Flask turns into a 400 response. Use it for parameters that are truly mandatory, and get() 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, a Decimal, datetime.date.fromisoformat, or your own function that raises ValueError on 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)

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)
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())

Three details of form handling cause most support questions:

  • form is empty for other content types. If the client sends application/json, text/plain or anything else, request.form is empty and the bytes are in request.data or request.get_json(). A common symptom is a form handler that works from the browser and returns nothing for a JavaScript fetch() that sent JSON.

  • A file input needs enctype="multipart/form-data". Without it the browser sends only the file name, as a text field, and request.files stays empty. In debug mode Flask replaces request.files with a dictionary that raises an explanatory DebugFilesKeyError for exactly this mistake. Outside debug mode you just see a BadRequestKeyError.

  • Parsing is guarded by limits. MAX_CONTENT_LENGTH, MAX_FORM_MEMORY_SIZE and MAX_FORM_PARTS apply 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

filename

The name the client claims. It is attacker-controlled: it may contain ../, drive letters, spaces or nothing at all. A browser submits an empty part with an empty filename when the user chose no file.

name

The form field name (cover in <input type="file" name="cover">).

content_type and mimetype

The type the client claims for the part. It is not verified. Check the real content (magic bytes, an image library) before trusting it.

content_length

The size the part declared in its own header, which is usually 0 because browsers do not send one. Do not use it for quota checks. Read the stream with a limit, or rely on MAX_CONTENT_LENGTH.

stream

The underlying file-like object. Small parts are held in memory and larger ones in a temporary file on disk.

save(dst)

Writes the part to a path or a file object. The destination must be built from a name you chose, or from werkzeug.utils.secure_filename(), never from filename directly.

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)

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:

  1. If the media type is not application/json (or application/*+json, such as application/vnd.api+json) and force is false, raise 415 Unsupported Media Type.

  2. Otherwise read the body (cached) and parse it with the JSON provider’s loads.

  3. If parsing fails, raise 400 Bad Request.

  4. With silent=True, both failures return None instead of raising.

  5. With force=True, the media type check is skipped and the body is parsed whatever the header says.

Situation Result Notes

Content-Type: application/json, valid JSON

the parsed value

any JSON type: object, array, string, number, true, false or null

Missing or different Content-Type (for example text/plain, or form data)

415

since Werkzeug 2.3 (2.1 and 2.2 answered 400, and Flask 1.x returned None)

Correct Content-Type, malformed JSON

400

the decoding message is replaced by a generic text unless the app is in debug mode

Correct Content-Type, empty body

400

an empty string is not valid JSON

Any failure with silent=True

None

indistinguishable from the JSON value null

Any content type with force=True

parsed, or 400

use only for clients you cannot fix, such as webhooks that send JSON as text/plain

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())
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 does request.get_json().get("name") fails with AttributeError (a 500) on a JSON array, a string, a number or null. Full-Stack Flask and React writes its handlers that way and also mixes request.get_json() with request.files.get() in the same view, which cannot work for one request as explained after the figure above.

  • Whitelist fields. is_admin in the payload was ignored because only title and pages are copied. Passing **payload to a model constructor is mass assignment and lets clients set fields you never meant to expose.

  • Validate types, not just presence. True is an int in Python, so a check on pages has to exclude bool explicitly. 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

request.get_data(cache=True, as_text=False, parse_form_data=False)

Reads the whole body. Cached by default, so later get_json() calls reuse it. Returns empty if form parsing already consumed the stream, unless parse_form_data=True. With as_text=True the bytes are decoded as UTF-8 with replacement characters for invalid bytes.

request.data

The raw bytes, but only if the body was not form data. For multipart/form-data and application/x-www-form-urlencoded it is empty because the parser consumed the stream. Use get_data() when you need the bytes regardless.

request.stream

The WSGI input stream wrapped with safety checks: it stops at Content-Length and at max_content_length, and it returns an empty stream rather than an endless one when neither bound is known. Use it to process a large body in chunks with stream.read(size) instead of loading it all.

request.input_stream

The raw wsgi.input with no checks. Avoid it.

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)

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

request.content_type, mimetype, mimetype_params

The raw header, the media type without parameters (text/csv), and the parameters as a dict (charset, boundary).

request.content_length

The declared length as an int or None. It is the client’s claim until the body is actually read.

request.accept_mimetypes, accept_languages, accept_encodings

Parsed Accept* headers with quality values. best_match(["application/json", "text/html"]) implements content negotiation.

request.authorization

An Authorization object or None: type (basic or bearer, lower-cased), username and password for Basic, token for Bearer.

request.if_none_match, if_modified_since, range, cache_control

Conditional and range request headers, used by send_file and make_conditional.

request.user_agent

An object whose string is the raw header. Werkzeug does not parse it any more, and the value is trivially forged.

request.origin, referrer, host

Origin, Referer and the validated Host (see Client Address, Scheme and Host Behind a Proxy).

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

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())

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

md["k"]

The first value, or BadRequestKeyError (a KeyError subclass that Flask maps to 400) when missing.

md.get("k", default, type=int)

The first value converted with type; the default if the key is missing or the conversion raises ValueError or TypeError (the TypeError case since Werkzeug 3.0.2).

md.getlist("k", type=int)

A list of all values; [] if missing; unconvertible values omitted (and TypeError is caught too since Werkzeug 3.1).

md.to_dict()

A plain dict with the first value of each key (this is what dict(md) gives too).

md.to_dict(flat=False)

A dict whose values are lists, which keeps duplicates.

md.items(multi=True)

Every (key, value) pair, repeated keys included. Plain items() yields only the first value of each key.

md.lists()

(key, [values]) pairs.

md.copy()

A mutable MultiDict with add(), setlist() and item assignment, for the cases where you need to modify a copy.

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

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

request.method

GET

Upper-cased HTTP method.

request.path

/books/42

Path after the script root, already percent-decoded. This is what routing matched.

request.full_path

/books/42?lang=es

Path plus ? plus the query string (it always contains the ?, even for an empty query).

request.query_string

b"lang=es"

The raw bytes of the query string.

request.script_root / request.root_path

/shelf

The prefix the application is mounted at.

request.url

http://books.example.com:8000/shelf/books/42?lang=es

The full URL, query string included.

request.base_url

http://books.example.com:8000/shelf/books/42

The URL without the query string.

request.url_root / request.root_url

http://books.example.com:8000/shelf/

Scheme, host and script root.

request.host_url

http://books.example.com:8000/

Scheme and host only.

request.host

books.example.com:8000

Host (with the port when not standard), validated against TRUSTED_HOSTS.

request.scheme / request.is_secure

http / False

From the WSGI server, unless a proxy fix is installed.

request.endpoint, view_args, url_rule, blueprint, blueprints

show_book, {"book_id": 42}, the matched Rule, the blueprint name, the list of blueprint names up the nesting

Flask’s routing result. All are None (or empty) when no rule matched, and routing_exception then holds the NotFound or MethodNotAllowed that will be raised.

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

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

x_for

1

Number of proxies that append to X-Forwarded-For. The value taken is the one added by the last trusted proxy, counting from the right.

x_proto

1

Number of proxies that set X-Forwarded-Proto (feeds scheme).

x_host

0

Number of proxies that set X-Forwarded-Host (feeds host).

x_port

0

Number of proxies that set X-Forwarded-Port.

x_prefix

0

Number of proxies that set X-Forwarded-Prefix (feeds script_root).

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 ProxyFix only 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_route is not fixed up. It is derived from the raw X-Forwarded-For header, whose first entry is client-supplied, so it is useful for logging the whole chain and wrong as an identity. Use remote_addr after ProxyFix.

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

MAX_CONTENT_LENGTH / request.max_content_length

None (no limit)

0.6 (per request: 3.1)

Maximum bytes read from the body, whatever its type. When it is None and the request has no Content-Length and the server does not say it terminates the stream, no body is read at all, to avoid an infinite stream.

MAX_FORM_MEMORY_SIZE / request.max_form_memory_size

500_000

3.1

Maximum size of any single non-file field in a multipart/form-data body. File parts are exempt and spill to disk.

MAX_FORM_PARTS / request.max_form_parts

1_000

3.1

Maximum number of fields and files in a multipart/form-data body. With the default memory limit a form can still occupy about 500 MB.

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_size in nginx, limit_request_line and 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.stream in chunks and write them out as you go, with max_content_length set 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())

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())

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

client.get("/books", query_string={"q": "dune", "tag": ["sf", "classic"]}), or the string in the URL.

A form body

client.post(url, data={"title": "Dune"}). A value that is a (file_object, filename) tuple becomes a file part and switches the content type to multipart automatically.

A JSON body

client.post(url, json={...}). It sets Content-Type: application/json and serializes with the app’s JSON provider.

A raw body

client.post(url, data=b"...", content_type="text/csv").

Headers and cookies

headers={...}, and client.set_cookie("name", "value") before the call.

A different client address or scheme

environ_overrides={"REMOTE_ADDR": "203.0.113.9"} and base_url="https://books.example.com/".

A request context without dispatching

with app.test_request_context("/books?q=dune", method="POST", json={...}): and then use request directly.

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, Origin and the forwarded headers), cookies other than the signed session, file names, content types and the declared Content-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.form or the JSON dictionary to Model(**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"] and files["x"] raise a 400 for you; headers["x"] raises KeyError and yields 500.

  • Check get_json() results. They can be a list, a string, a number or None, 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 read request.stream and also expect form to work.

  • Set size limits. MAX_CONTENT_LENGTH at the app and at the proxy; per-view overrides for the few routes that accept more. Keep MAX_FORM_MEMORY_SIZE and MAX_FORM_PARTS at their defaults or lower unless you have a reason.

  • Trust forwarded headers only through ProxyFix with the right hop counts, and set TRUSTED_HOSTS for the Host header.

  • Do not log whole requests. Bodies and Authorization or Cookie headers contain credentials; log the method, path, status and request id.

  • The proxy outlives the request. request works 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 use copy_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

request.is_xhr removed.

Werkzeug 2.0

request.values contains only args for GET requests; BaseRequest and the mixins were merged into one Request.

Flask 2.2

The request and application contexts are backed by contextvars.

Werkzeug 2.3

get_json() raises 415 (not 400) when the content type is not JSON; invalid bytes in the query string stay percent-encoded; the stream checks max_content_length while reading.

Werkzeug 3.0

The charset, url_charset and encoding_errors options were removed: text is always UTF-8. TypeError is caught by MultiDict.get(type=...) (3.0.2).

Flask 3.1 and Werkzeug 3.1

MAX_FORM_MEMORY_SIZE (default 500 kB) and MAX_FORM_PARTS config keys; max_content_length and the other limits settable per request; TRUSTED_HOSTS and Request.trusted_hosts checked during routing; OrderedMultiDict deprecated.

Werkzeug 3.1.7 and 3.1.8

Request.host validates the characters of the Host value and returns an empty string for a missing or invalid header.

Werkzeug 3.1.9

application/x-www-form-urlencoded is limited only by max_content_length, no longer by max_form_memory_size.

Werkzeug 3.2 (unreleased)

Requires Python 3.11 or later and removes OrderedMultiDict and ImmutableOrderedMultiDict; TypeConversionDict is deprecated, and type-converting get() stays on MultiDict.

Flask 3.2 (unreleased)

RequestContext merges into AppContext (the request proxy keeps working unchanged), and the test client parses IPv6 addresses with a port correctly. The other items in the unreleased changelog concern responses (redirect() returning 303) and other pages.

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)

request.is_xhr to detect Ajax (Flask Framework Cookbook, 2nd edition, views chapter)

Removed in Werkzeug 1.0. See Detecting an Ajax Request: check an explicit header, negotiate with accept_mimetypes, or use separate API routes. The 3rd edition already uses the X-Requested-With header.

A rule of thumb that GET reads request.args and POST reads request.form (both cookbooks)

True as a convention, but request.values reads both and for GET ignores the body since Werkzeug 2.0. A POST can also carry a query string, and JSON bodies are in neither.

request.get_json().get("name") and data = request.get_json() followed by data.get(...) (Full-Stack Flask and React, API chapters)

Fails with AttributeError on a non-object JSON body, with 415 on a wrong content type (Werkzeug 2.3) and with an opaque 400 on malformed JSON. Check the type of the result, validate and return a 4xx with a message, as in JSON Bodies: get_json() and request.json.

request.get_json() and request.files.get("avatar") in the same handler (Full-Stack Flask and React, speaker endpoint)

One request has one body. A JSON body cannot carry a file, and a multipart body cannot be read with get_json(). Send the fields in the multipart form (request.form) or use two endpoints.

request.form.get("price") strings passed straight to the database in the cookbooks

Convert with type= or explicit parsing and validate ranges; use a form library (Flask-WTF) or a schema library for anything bigger than a handful of fields.

No size limits in any of the books

Set MAX_CONTENT_LENGTH and keep the 3.1 multipart limits, see Limits on Request Size.

request.remote_addr passed to a GeoIP lookup (both cookbooks)

Behind a proxy it is the proxy’s address, so every visitor gets the same answer. Use ProxyFix with the correct number of hops, see Client Address, Scheme and Host Behind a Proxy.

Summary

  • flask.request is a proxy to a Werkzeug Request that parses lazily and caches. Its data containers are immutable.

  • Match the accessor to the content type: args for the query string, form and files for form and multipart bodies, get_json() for JSON, get_data() for raw bytes, headers and cookies for metadata.

  • MultiDict keeps 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_LENGTH and keep MAX_FORM_MEMORY_SIZE and MAX_FORM_PARTS in 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, scheme and host are only right after ProxyFix with the exact hop counts, and TRUSTED_HOSTS guards the Host header.

  • Method overrides and request checksums are WSGI-level patterns that run before routing and before the body is read.