Responses & JSON
|
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-19), Werkzeug 3.1.9 and Python 3.13, checked on 2026-10-07;
every example was executed with those versions through Flask’s test client. Flask 3.2 is not released yet: the
changes that the official changelog lists for it (most importantly, redirect() returning 303 instead of 302) are
described as upcoming where they matter and in their own section. The page follows the road of a return
value: what a view may return and the rules Flask applies to convert it, how to shape the response (status, headers,
cookies, caching), the special responses (redirects, errors, JSON, streams and files), and how the JSON layer can be
configured or replaced. It extends the Bookshelf application of the previous pages with a small catalogue of books.
From a Return Value to a Response
A view function returns a value, not necessarily a response object. After the view (and the error handlers, if
the view raised) have run, Flask calls Flask.make_response() to convert the value into an instance of the
application’s response_class, which by default is flask.Response, a thin subclass of Werkzeug’s
werkzeug.wrappers.Response. Werkzeug owns the HTTP side of that object (headers, cookies, status line,
conditional and range handling, file wrapping); Flask adds the application-aware parts: the JSON conversion through
the application’s JSON provider, a default mimetype, and the hooks that run on the response afterwards (the
after_request functions, the session cookie, signals), described in
Application & Request Lifecycle.
2-tuple: headers if dict, list, tuple or Headers, else status"] B -- "no" --> D C --> D{"type of body"} D -- "None" --> X["TypeError, HTTP 500"] D -- "str or bytes" --> R["Response(body), text/html"] D -- "dict or list" --> J["app.json.response(body), application/json"] D -- "generator or iterator" --> S["streamed Response(body)"] D -- "Response of response_class" --> U["used unchanged"] D -- "other Werkzeug Response" --> W["converted to response_class"] D -- "WSGI callable" --> G["called as a WSGI app, result wrapped"] R --> F["apply status and headers from the tuple"] J --> F S --> F U --> F W --> F G --> F F --> H["after_request functions, session cookie, WSGI server"]
Source: Flask API: Flask.make_response
What a View May Return
The rules in the diagram are the whole contract. They are worth reading in the form of a table because several of them changed over the years and older tutorials and books still teach the pre-change behaviour.
| The view returns | Flask builds | Since |
|---|---|---|
|
a response with the string encoded as UTF-8 and mimetype |
always |
|
a response with the bytes as body, same default mimetype |
always |
|
a JSON response (through |
1.1 |
|
a JSON response |
2.2 |
a generator or any iterator of |
a streamed response (no |
2.2 |
|
the response for |
0.9 |
an instance of |
the object itself, unchanged |
always |
another Werkzeug |
the object coerced to |
always |
a WSGI callable |
the callable is run as a WSGI application and its result is wrapped |
always |
|
|
always |
Anything else (a set, a model instance, a Decimal, a dataclass) is not allowed as a return value and raises a
TypeError too: convert it to a dict first, or call jsonify. The three most common pitfalls come from this table:
forgetting the return (an if branch that falls off the end of the function returns None), returning a tuple
of the wrong length (only 2 or 3 items are accepted), and returning a pre-encoded JSON string, which is sent as
text/html and, if you then call jsonify on it as well, is encoded twice.
from flask import Flask
app = Flask(__name__)
BOOKS = {
1: {"id": 1, "title": "Dune", "author": "Frank Herbert", "year": 1965},
2: {"id": 2, "title": "Solaris", "author": "Stanislaw Lem", "year": 1961},
}
@app.get("/")
def home():
return "<h1>Bookshelf</h1>" # str -> text/html
@app.get("/books")
def list_books():
return list(BOOKS.values()) # list -> application/json
@app.get("/books/<int:book_id>")
def get_book(book_id):
book = BOOKS.get(book_id)
if book is None:
return {"error": "book not found"}, 404 # (dict, status)
return book # dict -> application/json
@app.get("/health")
def health():
return "", 204 # (str, status): no content
@app.get("/forgotten")
def forgotten():
BOOKS.get(1) # no return: a bug
client = app.test_client()
assert client.get("/").content_type.startswith("text/html")
assert client.get("/books").json[0]["title"] == "Dune"
assert client.get("/books/2").json["author"] == "Stanislaw Lem"
assert client.get("/books/99").status_code == 404
assert client.get("/health").status_code == 204
app.logger.disabled = True # keep the expected traceback quiet
assert client.get("/forgotten").status_code == 500
The status in a tuple may be an integer or a string such as "201 Created", and the headers may be a dict, a
list of (name, value) pairs or a Werkzeug Headers object. A 2-tuple is read as (body, headers) when the second
item is a dict, list, tuple or Headers, and as (body, status) otherwise. When the body is already a
Response object, the status in the tuple replaces the one of the object and the headers are added to its own.
from flask import Flask
app = Flask(__name__) # a fresh application, same Bookshelf data
BOOKS = {1: {"id": 1, "title": "Dune"}, 2: {"id": 2, "title": "Solaris"}}
@app.post("/books")
def create_book():
book = {"id": max(BOOKS) + 1, "title": "Neuromancer", "author": "William Gibson", "year": 1984}
BOOKS[book["id"]] = book
# (body, status, headers): 201 Created plus a Location header for the new resource
return book, 201, {"Location": f"/books/{book['id']}"}
@app.get("/legacy-tuple")
def legacy_tuple():
# (body, headers): the second item is a dict, so it is read as headers
return "ok", {"X-Bookshelf-Edition": "2026"}
client = app.test_client()
created = client.post("/books")
assert created.status_code == 201
assert created.headers["Location"] == "/books/3"
assert client.get("/legacy-tuple").headers["X-Bookshelf-Edition"] == "2026"
Source: Flask API: Flask.make_response
The Response Object
Returning plain values is enough for most views. When you need to touch the response (add a header, set a cookie, change the cache policy) you need the object. There are three ways to get one:
-
flask.make_response(*args)takes exactly what a view may return (one value, or several that are treated as a tuple) and gives back a response object. Called with no arguments it creates an empty response. It is what you use inside a view, and also to turn the value returned by another view or by a decorated function into an object so a decorator can add headers. -
flask.Response(body, status, headers, mimetype=..., content_type=...)(the same constructor as Werkzeug’s) builds one directly; it is the way to send a body Flask does not know how to convert (CSV, XML, a PDF held in memory). -
app.response_classis the class used for all of them; replace it to change the defaults for the whole application.
The attributes you will use most are listed below. All of them are Werkzeug’s, so the Werkzeug documentation is the reference for details.
| Member | Purpose |
|---|---|
|
the number, or the full status line ( |
|
a case-insensitive multi-dict: |
|
the media type alone ( |
|
read or replace the body; the length header is recomputed. Not for streamed responses |
|
parse the body as JSON (used a lot in tests) |
|
see Cookies |
|
the caching machinery, see Caching and Conditional Responses |
|
flags used by file wrappers and streams; you rarely set them |
The class has three class attributes, default_mimetype (text/html), default_status (200) and
autocorrect_location_header (False in Werkzeug 3, so a relative Location header is sent as written, see
Redirects). Subclass Response to change them:
from flask import Flask, Response, make_response, request
app = Flask(__name__)
class XmlResponse(Response):
default_mimetype = "application/xml" # views that return a str now send XML
@app.get("/export.xml")
def export_xml():
# an explicit Response: the body, the status and a mimetype
return Response("<books><book id='1'>Dune</book></books>", status=200, mimetype="application/xml")
@app.get("/version")
def version():
response = make_response({"api": "bookshelf", "version": 3}) # dict -> JSON response object
response.headers["X-Api-Version"] = "3" # replace
response.headers.add("Link", '</books>; rel="collection"') # append
response.status_code = 200
return response
xml_app = Flask(__name__)
xml_app.response_class = XmlResponse
@xml_app.get("/catalogue")
def catalogue():
return "<books/>" # a str, but sent as application/xml
assert app.test_client().get("/export.xml").mimetype == "application/xml"
reply = app.test_client().get("/version")
assert reply.json["version"] == 3 and reply.headers["X-Api-Version"] == "3"
assert xml_app.test_client().get("/catalogue").mimetype == "application/xml"
Source: Flask API: Response Objects
Source: Werkzeug: Response
Status Codes in Practice
Flask does not decide the status for you except for 200 (a normal return), 404 and 405 (the router), 500 (an unhandled exception) and the redirects. Choosing the right one is part of the API design, which a later page on building REST APIs develops (resources, pagination, error payloads). The short list used by the Bookshelf API:
| Code | Use | Flask idiom |
|---|---|---|
200 |
a successful read or update that returns a body |
|
201 |
a resource was created; send its URL in |
|
202 |
accepted, the work continues in the background (a Celery task, for example) |
|
204 |
success with no body (a delete) |
|
301, 302, 303, 307, 308 |
redirects |
|
400, 404, 409, 422 |
bad request, missing, conflict (duplicate), semantically invalid |
|
401, 403 |
not authenticated, not allowed |
|
413, 415 |
body too large ( |
raised by Flask and Werkzeug itself |
A 204 response has no body by definition. Return an empty string (as above); a {} is a JSON object, not an
absence of content, and sending one signals a mistake in the view even where the server drops the body. Remember too that the status line is only half of
the contract: a client that sees 201 expects the Location header, a 405 carries an Allow header, and a
redirect needs its Location. Werkzeug adds the headers of the exceptions it raises for you (see abort).
Cookies
Cookies are response headers (Set-Cookie), so they are set on the response object, and they come back as a
read-only dictionary on request.cookies. Flask’s own session uses the same mechanism (a signed cookie, covered
in the sessions page of this section); use set_cookie for anything that is not session data: a language choice, a
dismissed banner, a signed token you manage yourself.
from datetime import timedelta
from flask import Flask, make_response, request
app = Flask(__name__)
@app.post("/preferences/theme/<name>")
def set_theme(name):
if name not in {"light", "dark"}:
return {"error": "unknown theme"}, 400
response = make_response({"theme": name})
response.set_cookie(
"theme",
name,
max_age=timedelta(days=365), # seconds or a timedelta; None = until the browser closes
secure=True, # only over HTTPS
httponly=True, # not readable from JavaScript
samesite="Lax", # not sent on cross-site sub-requests
path="/",
)
return response
@app.get("/preferences/theme")
def get_theme():
return {"theme": request.cookies.get("theme", "light")}
@app.delete("/preferences/theme")
def reset_theme():
response = make_response("", 204)
response.delete_cookie("theme", path="/", secure=True, httponly=True, samesite="Lax")
return response
client = app.test_client()
client.post("/preferences/theme/dark", base_url="https://localhost") # HTTPS, so the cookie is sent back
cookie = client.get_cookie("theme")
assert cookie.value == "dark" and cookie.http_only and cookie.secure and cookie.same_site == "Lax"
assert client.get("/preferences/theme", base_url="https://localhost").json == {"theme": "dark"}
client.delete("/preferences/theme", base_url="https://localhost")
assert client.get_cookie("theme") is None
Source: Werkzeug: Response.set_cookie
The parameters of set_cookie(key, value="", max_age=None, expires=None, path="/", domain=None, secure=False,
httponly=False, samesite=None, partitioned=False):
| Parameter | Meaning and advice |
|---|---|
|
lifetime in seconds or as a |
|
the URL prefix the cookie is sent for; |
|
|
|
send only over HTTPS; set it for anything that matters, and always together with HSTS in production |
|
hide the cookie from |
|
|
|
marks the cookie as |
Browsers limit a cookie to about 4 KB; Werkzeug warns when a cookie header exceeds max_cookie_size. Two more
points: the defaults of set_cookie are permissive (secure=False, httponly=False, no samesite), unlike the
defaults of the session cookie that Flask configures through the SESSION_COOKIE_* keys in
Configuration; and a response that sets a cookie must not be cached by a shared
cache, so add Cache-Control: private (or no-store) when the content depends on it. Never put secrets or
personal data in a cookie value that is not signed or encrypted: use the session, or sign it with ItsDangerous.
Redirects
flask.redirect(location, code=302, Response=None) returns a response with a Location header and a short HTML
body. Inside a request it calls app.redirect() (so a custom response_class is honoured); outside one it falls
back to Werkzeug’s function. Build the target with url_for instead of writing paths by hand, so a renamed route
does not leave a broken redirect behind.
The status code matters, because clients disagree about what to do with the request body:
| Code | Meaning | Method of the follow-up request |
|---|---|---|
301 |
moved permanently |
historically GET even for POST; cached by browsers |
302 |
found (temporary). The default in Flask 3.1 |
browsers change POST to GET, others keep the method |
303 |
see other |
always GET |
307 |
temporary redirect |
the same method and body |
308 |
permanent redirect |
the same method and body |
The Post/Redirect/Get pattern (answer a form submission with a redirect to a page, so that a refresh does not
resubmit it) is the main use of redirects in server-rendered applications. For it, 303 states the intent exactly,
and from Flask 3.2 on it will be the default: the changelog of the unreleased 3.2 says redirect returns 303 instead of
302, so the client always switches to GET instead of only a POST doing so, which keeps GET and POST redirects
behaving as they do today and is also what front-end libraries such as HTMX expect. Until then, pass
code=303 explicitly. For an API that must preserve the method (a redirect of a PUT to a new URL) use 307 or 308.
from urllib.parse import urlsplit
from flask import Flask, redirect, request, url_for
app = Flask(__name__)
BOOKS = {1: {"id": 1, "title": "Dune"}}
@app.get("/books/<int:book_id>")
def get_book(book_id):
return BOOKS[book_id]
@app.get("/books/<int:book_id>/edit")
def edit_book(book_id):
return f"edit form for {book_id}"
@app.post("/books/<int:book_id>/edit")
def save_book(book_id):
BOOKS[book_id]["title"] = request.form["title"]
# Post/Redirect/Get: 303 forces the browser to follow with a GET
return redirect(url_for("get_book", book_id=book_id), code=303)
@app.get("/old-catalogue")
def old_catalogue():
return redirect(url_for("get_book", book_id=1), code=308) # permanent, method preserved
def is_safe_target(target: str | None) -> bool:
"""Accept only same-site paths: no scheme, no host, no protocol-relative URL."""
if not target or "\\" in target or not target.startswith("/") or target.startswith("//"):
return False
parts = urlsplit(target)
return not parts.scheme and not parts.netloc
@app.post("/login")
def login():
# ... authenticate first, then:
target = request.args.get("next")
return redirect(target if is_safe_target(target) else url_for("get_book", book_id=1), code=303)
client = app.test_client()
saved = client.post("/books/1/edit", data={"title": "Dune Messiah"})
assert saved.status_code == 303 and saved.headers["Location"] == "/books/1"
assert BOOKS[1]["title"] == "Dune Messiah"
assert client.get("/old-catalogue").status_code == 308
assert client.post("/login?next=/books/1/edit").headers["Location"] == "/books/1/edit"
assert client.post("/login?next=https://evil.example/").headers["Location"] == "/books/1"
assert client.post("/login?next=//evil.example/").headers["Location"] == "/books/1"
followed = client.post("/books/1/edit", data={"title": "Dune"}, follow_redirects=True)
assert followed.json["title"] == "Dune" and followed.history[0].status_code == 303
Source: Flask API: flask.redirect
Pitfalls specific to redirects:
-
Open redirects. Redirecting to a
nextparameter taken from the query string, as the login example above does, lets an attacker craft a link on your domain that bounces the user to a hostile site. Only follow targets that are local paths. The check above rejects absolute URLs, protocol-relative//hostURLs and backslashes (which browsers treat as slashes); it is a starting point, and a maintained helper from your authentication extension is better when you have one. -
Relative
Locationheaders. Since Werkzeug 2.1 the header is sent as you wrote it (autocorrect_location_headerisFalse), which RFC 9110 allows. Pass an absolute URL (url_for(..., _external=True)) only when a client needs one. -
Redirect loops with
strict_slashes. A route declared with a trailing slash makes Flask redirect (308) the slash-less URL to it; do not add a second redirect that undoes it. -
The test client does not follow redirects unless you pass
follow_redirects=True; the intermediate responses are inresponse.history.
Raising Errors with abort
flask.abort(code, *args, **kwargs) raises the Werkzeug HTTPException registered for the status code (inside a
request, it asks app.aborter, so you can register your own). Execution stops at the call; the exception travels
to the registered error handler for its code or class, or to the default handler, which renders a small HTML page.
A response is built from the exception, so the status, the extra headers of the exception (Allow for 405,
Retry-After for 429 and 503) and the description are all sent for you.
from flask import Flask, abort, jsonify, make_response, request
app = Flask(__name__)
BOOKS = {1: {"id": 1, "title": "Dune", "author": "Frank Herbert"}}
@app.get("/books/<int:book_id>")
def get_book(book_id):
book = BOOKS.get(book_id)
if book is None:
abort(404, description=f"No book with id {book_id}") # stops here
return book
@app.post("/books")
def add_book():
data = request.get_json()
if any(b["title"] == data.get("title") for b in BOOKS.values()):
abort(409, description="A book with this title already exists")
BOOKS[len(BOOKS) + 1] = {"id": len(BOOKS) + 1, **data}
return data, 201
@app.get("/secret-shelf")
def secret_shelf():
# abort() also accepts a ready-made response, which is used as it is
abort(make_response(jsonify({"error": "authentication required"}), 401))
@app.errorhandler(404)
@app.errorhandler(409)
def json_error(error):
# a JSON body instead of the default HTML page (error handlers: see the Error Handling page of this section)
return {"error": error.name, "message": error.description}, error.code
client = app.test_client()
missing = client.get("/books/7")
assert missing.status_code == 404 and missing.json["message"] == "No book with id 7"
assert client.post("/books", json={"title": "Dune"}).status_code == 409
assert client.get("/secret-shelf").status_code == 401
Source: Flask API: flask.abort
Source: Werkzeug: HTTP Exceptions
Passing a response object instead of a code (abort(make_response(jsonify(...), 401)) above) raises an
HTTPException that carries that response, with the status the response already has. Use abort for control flow in helpers and decorators deep in the call stack, where a
return cannot reach the view; use a normal return {...}, code when the view itself decides. Structured error
payloads, handlers for HTTPException as a class, werkzeug.exceptions.HTTPException subclasses of your own and
the handling of unexpected exceptions belong to the error handling page that follows in this section.
JSON Responses
There are two equivalent ways to answer with JSON: return a dict or a list (the view’s return value is passed to
jsonify for you), or call flask.jsonify(...) yourself when you need the response object (to set headers or a
status, to wrap it in make_response) or when you want to send something that is neither of those.
jsonify(*args, **kwargs) takes either one positional value, several positional values (serialized as a list), or
keyword arguments (serialized as an object), never both; with no arguments it serializes None, which is the JSON
value null. The result is a response with mimetype application/json.
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/a")
def a():
return {"title": "Dune", "year": 1965} # dict: implicit jsonify
@app.get("/b")
def b():
return jsonify(title="Dune", year=1965) # keyword arguments: an object
@app.get("/c")
def c():
return jsonify("Dune", "Solaris") # several positional values: an array
@app.get("/d")
def d():
response = jsonify({"title": "Dune"})
response.status_code = 201 # a Response object, so it can be shaped
response.headers["Location"] = "/books/1"
return response
client = app.test_client()
assert client.get("/a").json == client.get("/b").json == {"title": "Dune", "year": 1965}
assert client.get("/c").json == ["Dune", "Solaris"]
assert client.get("/d").status_code == 201
assert client.get("/a").content_type == "application/json"
Source: Flask API: flask.json.jsonify
Behaviour worth knowing, because it differs from what many people expect:
-
Sorted keys. The default provider sorts the keys of every object (
sort_keys = True), so the output does not follow the insertion order of your dictionary. This is deterministic (good for caching and for diffs), but it surprises people who send ordered columns. Turn it off withapp.json.sort_keys = False; a dictionary with keys of mixed types cannot be sorted at all and fails unless you do. -
Compact output. Out of debug mode the output has no indentation or spaces after the separators, and a trailing newline is added; in debug mode it is pretty printed. Set
app.json.compacttoTrueorFalseto force either. -
ASCII output.
ensure_ascii = Trueescapes every non-ASCII character as\uXXXX, which every JSON parser understands but which inflates the size of text in other languages. Set it toFalseand the body is encoded as UTF-8. -
Extra types. Beyond the standard types, the default provider converts
datetime.dateanddatetime.datetimeto RFC 822 strings (the HTTP date format, such asThu, 07 Oct 2026 10:00:00 GMT), not ISO 8601;decimal.Decimalanduuid.UUIDto strings; dataclasses throughdataclasses.asdict; and objects with an__html__method (such asmarkupsafe.Markup) to their HTML string. Anything else raisesTypeError. -
Non-finite numbers. The standard library accepts
float("nan")and writes the tokenNaN, which is not valid JSON; strict clients reject it. Clean the data, or passallow_nan=Falsetodumpsin a custom provider. -
Never double-encode.
return json.dumps(data)sends a string astext/html;jsonify(json.dumps(data))sends a JSON string that contains JSON. The fix in both cases is to return thedictor thelist. This is a mistake seen in the REST samples of the Flask books that build their resources with Flask-RESTful andjson.dumps. -
Top-level arrays are fine. Old guidance said a JSON response must be an object because of a cross-site data theft trick that affected very old browsers (JSON hijacking); current Flask sends arrays as they are.
from datetime import date
from decimal import Decimal
from uuid import UUID
from flask import Flask
app = Flask(__name__)
@app.get("/offer")
def offer():
return {"title": "Zażółć", "price": Decimal("9.90"), "published": date(2026, 10, 7), "sku": UUID(int=1)}
client = app.test_client()
body = client.get("/offer").get_data(as_text=True)
print(body)
app.json.sort_keys = False # keep insertion order
app.json.ensure_ascii = False # UTF-8 instead of escapes
app.json.compact = False # indent even outside debug mode
print(client.get("/offer").get_data(as_text=True))
Source: Flask API: DefaultJSONProvider
Running the first print writes, on one line, the sorted and escaped form (keys in alphabetical order, the title as
Zażółć, the date as an RFC 822 string); the second print writes an indented object with the keys in
insertion order and the Polish text readable.
The JSON Provider
All the JSON work of Flask (jsonify, dict and list returns, request.get_json(), the tojson template filter, the
signed session cookie, flask.json.dumps and loads) goes through one object, app.json, an instance of a
flask.json.provider.JSONProvider subclass. Flask 2.2 introduced it; it replaced the json_encoder and
json_decoder attributes and the JSON_* configuration keys, which were deprecated in 2.2 and removed in 2.3.
Code, books and answers that still configure app.json_encoder = MyEncoder, JSON_SORT_KEYS or JSON_AS_ASCII do
nothing on a current Flask; configure the provider instead.
DefaultJSONProvider, the provider in use unless you replace it, is built on the standard library and exposes these
attributes (set them on the instance, app.json.sort_keys = False, or in a subclass):
| Attribute | Default | Effect |
|---|---|---|
|
a function handling dates, |
called for any object the encoder cannot serialize; return a JSON-compatible value or raise |
|
|
escape non-ASCII characters |
|
|
sort object keys |
|
|
|
|
|
the content type that |
The methods are dumps(obj, **kwargs), dump(obj, fp, **kwargs), loads(s, **kwargs), load(fp, **kwargs) and
response(*args, **kwargs); the keyword arguments of the first four go to json.dumps and json.loads.
A custom default. The usual need is a type of your own (a model, a set, a Path, an ISO date). Do not replace the
default function wholesale and lose the built-in conversions: wrap it.
from datetime import datetime, date
from flask import Flask
from flask.json.provider import DefaultJSONProvider
class Book:
def __init__(self, title, tags):
self.title, self.tags = title, set(tags)
def bookshelf_default(o):
if isinstance(o, (datetime, date)):
return o.isoformat() # ISO 8601 instead of RFC 822
if isinstance(o, set):
return sorted(o)
if isinstance(o, Book):
return {"title": o.title, "tags": o.tags} # the set inside is handled by the same function
return DefaultJSONProvider.default(o) # Decimal, UUID, dataclasses, __html__ and the TypeError
class BookshelfJSONProvider(DefaultJSONProvider):
default = staticmethod(bookshelf_default)
sort_keys = False
ensure_ascii = False
app = Flask(__name__)
app.json = BookshelfJSONProvider(app) # the instance form; see json_provider_class below
@app.get("/book")
def book():
return {"book": Book("Dune", {"sf", "classic"}), "added": datetime(2026, 10, 7, 9, 30)}
payload = app.test_client().get("/book").json
assert payload["added"] == "2026-10-07T09:30:00"
assert payload["book"]["tags"] == ["classic", "sf"]
Source: Flask API: DefaultJSONProvider
Two ways to install a provider. Assign an instance, app.json = BookshelfJSONProvider(app), after the application is
created (a good place is the application factory), or set the class attribute json_provider_class on a subclass of
Flask, which is the way to make every application of your project use it, including those created in tests:
from flask import Flask
class BookshelfApp(Flask):
json_provider_class = BookshelfJSONProvider # instantiated for every application of this class
app = BookshelfApp(__name__)
assert isinstance(app.json, BookshelfJSONProvider)
A different JSON library. To change the engine, subclass JSONProvider and implement at least dumps and loads;
the other methods (dump, load, response) have working implementations built on them. The
flask-orjson extension is exactly this, a provider on top of
orjson, a fast Rust JSON library that serializes dataclasses, datetime (in ISO 8601) and
uuid natively and speeds up reading request bodies too. Version 2.0.0 of the extension is current:
from flask import Flask
from flask_orjson import OrjsonProvider
app = Flask(__name__)
app.json = OrjsonProvider(app) # after this, jsonify, get_json and the session use orjson
@app.get("/fast")
def fast():
return {"title": "Dune", "year": 1965} # keys are not sorted by orjson: insertion order is kept
assert app.test_client().get("/fast").get_data() == b'{"title":"Dune","year":1965}'
Source: flask-orjson documentation
The caveats of a faster provider: it is not a drop-in for every behaviour (orjson writes bytes, rejects
integers beyond 64 bits and non-string dictionary keys unless told otherwise through its option flags, which
the provider exposes as its option attribute), so run your test suite after the switch; and the gain only shows on
large payloads, not on the typical 200-byte response. Measure before adopting it.
Without a request. flask.json.dumps() and loads() use current_app.json when an application context is active
and fall back to the standard library otherwise; use app.json.dumps() in a script to be explicit. The module
flask.json.tag (the tagged serializer behind the session cookie) is a separate piece of the JSON layer, because
it must round-trip types such as datetime, UUID and bytes through a cookie; it belongs to the sessions page.
Streaming Responses
A generator returned from a view (or passed to Response) is consumed lazily and its chunks are written to the client
as they are produced. This keeps memory flat for large exports and makes progress visible for long jobs. There is no
Content-Length header on such a response, so clients and proxies use chunked transfer encoding or close the
connection to mark the end, depending on the server.
The catch is the request context. Flask pops it after the view returns, and a generator runs after the view has
returned, while the WSGI server iterates the body, so request, session, g and current_app are no longer
available inside it. Wrap the generator in flask.stream_with_context() to keep the context pushed until the
generator is exhausted. It works as a call, stream_with_context(generator()), or as a decorator on a generator
function. Read what you need from request before the first yield when you can, which is also safer.
import csv
import io
from flask import Flask, Response, request, stream_with_context
app = Flask(__name__)
BOOKS = [{"id": i, "title": f"Book {i}", "year": 1900 + i} for i in range(1, 6)]
@app.get("/books.csv")
def export_csv():
min_year = request.args.get("min_year", 0, type=int)
@stream_with_context
def generate():
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["id", "title", "year"])
yield buffer.getvalue()
for book in BOOKS:
if book["year"] < min_year:
continue
buffer.seek(0)
buffer.truncate()
writer.writerow([book["id"], book["title"], book["year"]])
yield buffer.getvalue()
headers = {"Content-Disposition": 'attachment; filename="books.csv"'}
return Response(generate(), mimetype="text/csv", headers=headers)
reply = app.test_client().get("/books.csv?min_year=1903")
assert reply.headers["Content-Disposition"].startswith("attachment")
assert reply.get_data(as_text=True).splitlines()[1:] == ["3,Book 3,1903", "4,Book 4,1904", "5,Book 5,1905"]
assert "Content-Length" not in reply.headers
Source: Flask: Streaming Contents
A Server-Sent Events endpoint is the same mechanism with the text/event-stream mimetype and the event format
(data: ... lines ended by a blank line). The browser’s EventSource reconnects by itself. It is a good fit for
one-way updates (the progress of an import, a notification counter) and much simpler than WebSockets, which Flask
does not provide on its own (see the page about real-time features for flask-sock and Flask-SocketIO).
import json
import time
from flask import Flask, Response, stream_with_context
app = Flask(__name__)
@app.get("/imports/<int:job_id>/events")
def import_events(job_id):
def events():
for done in range(0, 101, 50):
yield f"event: progress\ndata: {json.dumps({'job': job_id, 'percent': done})}\n\n"
time.sleep(0.01) # a real view would wait for the worker here
yield "event: done\ndata: {}\n\n"
response = Response(stream_with_context(events()), mimetype="text/event-stream")
response.headers["Cache-Control"] = "no-cache" # an event stream must never be cached
return response
text = app.test_client().get("/imports/7/events").get_data(as_text=True)
assert text.count("event: progress") == 3 and text.endswith("event: done\ndata: {}\n\n")
Source: Flask: Streaming Contents
Production caveats of streaming: every open stream occupies a worker for its whole duration (a synchronous Gunicorn or Waitress worker serves one at a time, so use threads, gevent workers or an asynchronous server for many long streams); a reverse proxy may buffer the response and defeat the purpose, so disable response buffering for these routes in the proxy configuration; and a failure in the middle of the generator cannot change the status line that was already sent, only truncate the body.
Caching and Conditional Responses
HTTP caching is carried by response headers, and Werkzeug models them as attributes, so you rarely write them as
strings. response.cache_control is a mutable object (max_age, public, private, no_store, no_cache,
must_revalidate, …), response.expires and response.last_modified take datetimes, and response.add_etag()
computes a strong ETag from the body. response.make_conditional(request) compares the request’s conditional headers
(If-None-Match, If-Modified-Since, Range…) with the response and turns it into a 304 Not Modified or a
206 Partial Content when appropriate, discarding the body.
from flask import Flask, make_response, request
app = Flask(__name__)
BOOKS = {1: {"id": 1, "title": "Dune", "author": "Frank Herbert"}}
@app.get("/books/<int:book_id>")
def get_book(book_id):
response = make_response(BOOKS[book_id])
response.cache_control.public = True
response.cache_control.max_age = 60 # clients may reuse it for a minute
response.add_etag() # strong ETag computed from the body
return response.make_conditional(request) # 304 if the client already has this version
client = app.test_client()
first = client.get("/books/1")
assert first.status_code == 200 and "max-age=60" in first.headers["Cache-Control"]
again = client.get("/books/1", headers={"If-None-Match": first.headers["ETag"]})
assert again.status_code == 304 and again.get_data() == b""
BOOKS[1]["title"] = "Dune (revised)"
assert client.get("/books/1", headers={"If-None-Match": first.headers["ETag"]}).status_code == 200
Responses that depend on the user (anything read through the session or an Authorization header) must not be
stored by shared caches: send Cache-Control: private or no-store, and Vary for the request headers they depend
on (Flask already adds Vary: Cookie when the session is accessed, since 2.3.2).
Content negotiation is the other half of the response choice: one URL, several representations. The parsed
Accept header is request.accept_mimetypes, and best_match() returns the best of the types you offer, or None.
from flask import Flask, request
app = Flask(__name__)
BOOK = {"id": 1, "title": "Dune"}
@app.get("/books/1")
def book():
best = request.accept_mimetypes.best_match(["application/json", "text/html"], default="application/json")
if best == "text/html":
return f"<h1>{BOOK['title']}</h1>"
return BOOK
client = app.test_client()
assert client.get("/books/1", headers={"Accept": "text/html"}).mimetype == "text/html"
assert client.get("/books/1", headers={"Accept": "application/json"}).json["id"] == 1
assert client.get("/books/1", headers={"Accept": "*/*"}).mimetype == "application/json"
Source: Werkzeug: MIMEAccept
Prefer separate routes (or a path suffix) to branching on X-Requested-With or request.is_xhr: the latter was
removed in Werkzeug 1.0 and is still found in older Flask tutorials and one of the books of the bibliography. The HTML
that goes in a text/html branch should come from a template (autoescaped), not from an f-string as the shortened
example does; the templating page covers it.
Shaping Every Response
Some headers belong to every response: security headers, a request id, CORS headers, a version header. Apply them in
one place with an after_request function, which receives the response object, must return a response and runs
only when the view (or an error handler) produced one. A teardown_request function does not see the response. The
full order of hooks is in Application & Request Lifecycle,
and the dedicated page on hooks covers after_this_request and the others.
import secrets
from flask import Flask
app = Flask(__name__)
@app.after_request
def add_default_headers(response):
response.headers.setdefault("X-Content-Type-Options", "nosniff") # no MIME sniffing
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("X-Request-Id", secrets.token_hex(8))
return response
@app.get("/")
def home():
return {"status": "ok"}
headers = app.test_client().get("/").headers
assert headers["X-Content-Type-Options"] == "nosniff" and len(headers["X-Request-Id"]) == 16
A complete set (Content-Security-Policy, HSTS, frame options) is easier to maintain with a library such as Flask-Talisman than by hand; the security page of the official docs lists the headers and what they protect.
Sending Files
flask.send_file() turns a file path (or a binary file object) into a response, using Werkzeug to guess the mimetype,
compute the validators and honour conditional and range requests. flask.send_from_directory(directory, path,
**kwargs) is the variant for paths that come from a client: it joins path to directory with
werkzeug.security.safe_join, answers 404 when the result escapes the directory or is not a regular file, and then
calls send_file.
Parameter of send_file |
Default | Meaning |
|---|---|---|
|
required |
a path ( |
|
guessed from the file name |
set it for files without a recognizable extension or when you send a file object |
|
|
|
|
the file name |
the name proposed to the user; the only place where a file object can get one. Replaces |
|
|
answer |
|
|
generate an ETag from the file’s modification time, size and name; or pass a string to use. Replaces |
|
from the file |
a datetime or number of seconds to send instead |
|
|
seconds the client may cache the file; when set, |
import io
import tempfile
from pathlib import Path
from flask import Flask, send_file, send_from_directory
app = Flask(__name__)
EXPORTS = Path(tempfile.mkdtemp()) # a configured absolute directory in a real application
(EXPORTS / "catalogue.txt").write_text("Dune\nSolaris\n")
app.config["EXPORT_DIR"] = EXPORTS
@app.get("/exports/<path:name>")
def download_export(name):
# name comes from the client: send_from_directory refuses ../ tricks and answers 404
return send_from_directory(app.config["EXPORT_DIR"], name, as_attachment=True, max_age=3600)
@app.get("/report.csv")
def report():
csv_bytes = b"id,title\n1,Dune\n2,Solaris\n"
return send_file(io.BytesIO(csv_bytes), mimetype="text/csv", as_attachment=True, download_name="report.csv")
@app.get("/cover.txt")
def cover():
return send_file(app.config["EXPORT_DIR"] / "catalogue.txt") # trusted path built by the server
client = app.test_client()
reply = client.get("/exports/catalogue.txt")
assert reply.status_code == 200 and "attachment" in reply.headers["Content-Disposition"]
assert "max-age=3600" in reply.headers["Cache-Control"]
assert client.get("/exports/../app.py").status_code == 404
assert client.get("/exports/missing.txt").status_code == 404
assert client.get("/report.csv").headers["Content-Disposition"] == "attachment; filename=report.csv"
# conditional request: the ETag from the first response avoids sending the file again
tag = reply.headers["ETag"]
assert client.get("/exports/catalogue.txt", headers={"If-None-Match": tag}).status_code == 304
# range request: only the first four bytes (Content-Range tells where they fit)
partial = client.get("/cover.txt", headers={"Range": "bytes=0-3"})
assert partial.status_code == 206 and partial.data == b"Dune"
assert partial.headers["Content-Range"] == "bytes 0-3/13"
assert client.get("/cover.txt").headers["Cache-Control"] == "no-cache" # no max_age: revalidate every time
Source: Flask API: flask.send_file
The default cache lifetime comes from SEND_FILE_MAX_AGE_DEFAULT (default None, since Flask 2.0, which means
no-cache plus validators: always revalidate, usually answered by a cheap 304). It applies to send_file calls
without max_age and to the built-in static route. To pick the lifetime per file, override
Flask.get_send_file_max_age(filename) (and the same method on Blueprint), which receives the path and returns
seconds or None. Fingerprinted file names (app.4f9a2c.js) can safely use a year; a name that stays the same
across deployments must not.
Security rules for file responses:
-
Never pass a client-supplied value to
send_file. Usesend_from_directory, and take itsdirectoryfrom the configuration, not from the request. The same holds foras_attachment: an uploaded HTML or SVG file served inline from your domain is a stored cross-site-scripting vector, so send user uploads as attachments, from a separate host name when possible, and with a mimetype you chose (andX-Content-Type-Options: nosniff). -
Pass
download_nameexplicitly when it comes from user data and keep it free of path separators; Werkzeug encodes theContent-Dispositionfile name for you, so non-ASCII names are fine. -
A file object must be opened in binary mode. A text-mode file raises
ValueError(since 2.0) rather than sending an empty body, as old versions did. -
Writing to disk and then sending is wasteful for generated content: build the bytes in an
io.BytesIOand send that (as/report.csvdoes); range requests on aBytesIOwork since Flask 1.1.
Offloading to the web server with USE_X_SENDFILE. Python is a poor file server: it ties up a worker for the
duration of the transfer. Many HTTP servers can take over the sending when the application only names the file in a
response header. Set USE_X_SENDFILE = True and send_file adds an X-Sendfile header with the absolute path and an
empty body; the front server (Apache with mod_xsendfile, Lighttpd, or Nginx when configured to honour the header) then reads the
file and sends it. It must be enabled only when the front server really intercepts the header, otherwise clients
receive an empty body; and the server must be allowed to read the directory. With Nginx the
equivalent feature is an internal redirect header that the application sets on the response itself.
from pathlib import Path
import tempfile
from flask import Flask, send_file
app = Flask(__name__)
app.config["USE_X_SENDFILE"] = True # in production: only behind a server that handles the header
path = Path(tempfile.mkdtemp()) / "cover.txt"
path.write_text("Dune\n")
@app.get("/cover")
def cover():
return send_file(path)
reply = app.test_client().get("/cover")
assert reply.headers["X-Sendfile"] == str(path) and reply.data == b""
Static files, which also use send_from_directory underneath, and the production serving of assets are the subject of
the static files page; the upload side (receiving files, size limits, secure_filename) is covered on the page about
file uploads.
Configuration Keys That Affect Responses
| Key | Default | Effect on responses |
|---|---|---|
|
|
|
|
|
default |
|
|
Werkzeug warns when a |
|
see the configuration page |
flags of the session cookie only; |
|
|
scheme of external URLs built outside a request, for example in redirects with |
|
|
decide whether an |
The old JSON_AS_ASCII, JSON_SORT_KEYS, JSONIFY_PRETTYPRINT_REGULAR and JSONIFY_MIMETYPE keys are gone
(deprecated in 2.2, removed in 2.3): they are now the ensure_ascii, sort_keys, compact and mimetype
attributes of the JSON provider described above.
Testing Responses
The test client returns a werkzeug.test.TestResponse, a Response with a few extras. Everything this page checks is
available in a test: response.status_code, .headers, .content_type, .mimetype, .data and .get_data(as_text=True),
.json (the parsed body, or None when the content type is not JSON), .location for redirects and .history when
following them, and client.get_cookie(name) for the cookie jar. The page on testing builds the fixtures around these.
import pytest
from flask import Flask
def create_app():
app = Flask(__name__)
@app.get("/books/<int:book_id>")
def get_book(book_id):
if book_id != 1:
return {"error": "not found"}, 404
return {"id": 1, "title": "Dune"}
return app
@pytest.fixture
def client():
return create_app().test_client()
@pytest.mark.parametrize("book_id, status", [(1, 200), (2, 404)])
def test_get_book(client, book_id, status):
response = client.get(f"/books/{book_id}")
assert response.status_code == status
assert response.mimetype == "application/json"
# the same checks run here without pytest
c = create_app().test_client()
assert c.get("/books/1").json == {"id": 1, "title": "Dune"}
assert c.get("/books/2").json == {"error": "not found"}
Recent Changes and Where Older Material Is Outdated
| Version | Change | What to do with older code |
|---|---|---|
Flask 1.1 |
a |
drop |
Flask 2.0 / Werkzeug 2.0 |
|
update the call sites; the old names raise |
Flask 2.2 |
the JSON provider ( |
replace |
Flask 2.3 |
|
move the settings to the provider’s attributes |
Werkzeug 1.0 |
|
use a separate API route or an explicit header |
Werkzeug 2.3 |
|
send |
Werkzeug 3.1 |
|
use it for CHIPS cookies |
Flask 3.2 (unreleased) |
|
pass |
Where the Flask books of the bibliography are outdated for this topic:
-
The REST chapters that return
json.dumps(...)from Flask-RESTful resources send the wrong content type or encode twice. Return adict, orjsonify. -
Examples that set
app.json_encoder,JSON_SORT_KEYSorJSONIFY_PRETTYPRINT_REGULAR, or callsend_file(..., attachment_filename=..., cache_timeout=...), fail on a current Flask; use the JSON provider and thedownload_nameandmax_ageparameters. -
Code that builds
make_response(jsonify(...))and sets the status afterwards works, but a(dict, status)tuple says the same in one line. -
Code that checks
request.is_xhrto decide between HTML and JSON no longer runs on Werkzeug; see the content negotiation example.
Summary
-
A view returns a
str,bytes,dict,list, generator, tuple orResponse;Noneis the one value that always fails. -
Use
make_response(or build aResponse) when you must touch headers, cookies or caching; use a(body, status, headers)tuple for the common case. -
set_cookiedefaults are permissive: setsecure,httponlyandsamesiteexplicitly, and delete a cookie with the samepathanddomain. -
Redirect with
url_for; today passcode=303for Post/Redirect/Get (the default changes from 302 to 303 in Flask 3.2); never follow an uncheckednextparameter. -
Return a
dictor alistfor JSON and configure the output through the JSON provider (app.json), not through removed config keys; wrapdefaultrather than replacing it; flask-orjson swaps the engine. -
Stream large or slow responses with generators and
stream_with_context; serve files withsend_fileandsend_from_directory, with a configured directory,download_nameandmax_age, andUSE_X_SENDFILEbehind a server that supports it.