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.

flowchart TD A["view returns rv"] --> B{"is rv a tuple?"} B -- "yes" --> C["unpack: body, status, headers
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"]
Figure 1. How Flask converts what a view returns into a response object

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

str

a response with the string encoded as UTF-8 and mimetype text/html

always

bytes

a response with the bytes as body, same default mimetype

always

dict

a JSON response (through app.json.response())

1.1

list

a JSON response

2.2

a generator or any iterator of str or bytes

a streamed response (no Content-Length)

2.2

(body, status, headers), (body, status) or (body, headers)

the response for body, with the status and the headers applied

0.9

an instance of app.response_class

the object itself, unchanged

always

another Werkzeug Response

the object coerced to app.response_class

always

a WSGI callable

the callable is run as a WSGI application and its result is wrapped

always

None, or no return at all

TypeError, which becomes a 500 response

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"

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_class is 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

status_code, status

the number, or the full status line ("404 NOT FOUND"); assign either

headers

a case-insensitive multi-dict: headers["X-Id"] = "7" replaces, headers.add("Link", "...") appends a second header of the same name, headers.getlist("Set-Cookie") reads all of them

mimetype, content_type

the media type alone (text/csv) or with parameters (text/csv; charset=utf-8)

get_data(as_text=False), set_data(value)

read or replace the body; the length header is recomputed. Not for streamed responses

json, get_json()

parse the body as JSON (used a lot in tests)

set_cookie(...), delete_cookie(...)

see Cookies

cache_control, expires, last_modified, etag, add_etag(), make_conditional(request)

the caching machinery, see Caching and Conditional Responses

direct_passthrough, is_streamed

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"

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

return book

201

a resource was created; send its URL in Location

return book, 201, {"Location": url}

202

accepted, the work continues in the background (a Celery task, for example)

return {"task": task_id}, 202

204

success with no body (a delete)

return "", 204

301, 302, 303, 307, 308

redirects

redirect(url, code=...), see Redirects

400, 404, 409, 422

bad request, missing, conflict (duplicate), semantically invalid

abort(code) or return {...}, code

401, 403

not authenticated, not allowed

abort(401), abort(403)

413, 415

body too large (MAX_CONTENT_LENGTH), not a JSON content type

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

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

max_age

lifetime in seconds or as a timedelta; None makes a session cookie that disappears with the browser. Prefer it over expires, which takes a datetime or a timestamp

path

the URL prefix the cookie is sent for; / by default. A cookie can only be deleted with the same path and domain it was set with, which is the most common reason a "deleted" cookie survives

domain

None makes the cookie host-only (sent only to the host that set it); setting example.com shares it with every sub-domain

secure

send only over HTTPS; set it for anything that matters, and always together with HSTS in production

httponly

hide the cookie from document.cookie, which limits what a cross-site-scripting bug can steal

samesite

"Lax", "Strict" or "None" (which also requires secure). "Lax" is the sensible default for most cookies

partitioned

marks the cookie as Partitioned (the CHIPS mechanism, for third-party cookies that must be kept per top-level site). The parameter was added in Werkzeug 3.1

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

Pitfalls specific to redirects:

  • Open redirects. Redirecting to a next parameter 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 //host URLs 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 Location headers. Since Werkzeug 2.1 the header is sent as you wrote it (autocorrect_location_header is False), 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 in response.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

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"

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 with app.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.compact to True or False to force either.

  • ASCII output. ensure_ascii = True escapes every non-ASCII character as \uXXXX, which every JSON parser understands but which inflates the size of text in other languages. Set it to False and the body is encoded as UTF-8.

  • Extra types. Beyond the standard types, the default provider converts datetime.date and datetime.datetime to RFC 822 strings (the HTTP date format, such as Thu, 07 Oct 2026 10:00:00 GMT), not ISO 8601; decimal.Decimal and uuid.UUID to strings; dataclasses through dataclasses.asdict; and objects with an __html__ method (such as markupsafe.Markup) to their HTML string. Anything else raises TypeError.

  • Non-finite numbers. The standard library accepts float("nan") and writes the token NaN, which is not valid JSON; strict clients reject it. Clean the data, or pass allow_nan=False to dumps in a custom provider.

  • Never double-encode. return json.dumps(data) sends a string as text/html; jsonify(json.dumps(data)) sends a JSON string that contains JSON. The fix in both cases is to return the dict or the list. This is a mistake seen in the REST samples of the Flask books that build their resources with Flask-RESTful and json.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))

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

default

a function handling dates, Decimal, UUID, dataclasses and __html__ objects

called for any object the encoder cannot serialize; return a JSON-compatible value or raise TypeError

ensure_ascii

True

escape non-ASCII characters

sort_keys

True

sort object keys

compact

None

None: compact outside debug mode and indented in debug mode; True or False force it

mimetype

"application/json"

the content type that response() sets (for example "application/vnd.api+json")

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

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}'

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

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

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"

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

path_or_file

required

a path (str or os.PathLike) or a file object opened in binary mode (positioned at the start). Paths are preferred: Werkzeug can then get the size, the modification time and an ETag. Use absolute paths; do not rely on how a relative one is resolved

mimetype

guessed from the file name

set it for files without a recognizable extension or when you send a file object

as_attachment

False

True sends Content-Disposition: attachment, so a browser offers to save the file instead of showing it

download_name

the file name

the name proposed to the user; the only place where a file object can get one. Replaces attachment_filename

conditional

True

answer If-None-Match, If-Modified-Since and Range (304 and 206); needs a path and the request environment

etag

True

generate an ETag from the file’s modification time, size and name; or pass a string to use. Replaces add_etags

last_modified

from the file

a datetime or number of seconds to send instead

max_age

None

seconds the client may cache the file; when set, Cache-Control: public, max-age=... is sent, otherwise Cache-Control: no-cache, which forces revalidation. Replaces cache_timeout

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

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. Use send_from_directory, and take its directory from the configuration, not from the request. The same holds for as_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 (and X-Content-Type-Options: nosniff).

  • Pass download_name explicitly when it comes from user data and keep it free of path separators; Werkzeug encodes the Content-Disposition file 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.BytesIO and send that (as /report.csv does); range requests on a BytesIO work 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

USE_X_SENDFILE

False

send_file sends an X-Sendfile header instead of the body

SEND_FILE_MAX_AGE_DEFAULT

None

default max_age for send_file and static files (no-cache while None)

MAX_COOKIE_SIZE

4093

Werkzeug warns when a Set-Cookie header is larger

SESSION_COOKIE_* (NAME, SECURE, HTTPONLY, SAMESITE, PARTITIONED, DOMAIN, PATH)

see the configuration page

flags of the session cookie only; set_cookie ignores them

PREFERRED_URL_SCHEME

"http"

scheme of external URLs built outside a request, for example in redirects with _external=True

TRAP_HTTP_EXCEPTIONS, PROPAGATE_EXCEPTIONS

False, None

decide whether an abort becomes a response or propagates; used when debugging and testing

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 dict return value becomes a JSON response; BytesIO supports range requests

drop jsonify(...) wrappers where a plain dict is enough

Flask 2.0 / Werkzeug 2.0

send_file renamed attachment_filename to download_name, cache_timeout to max_age and add_etags to etag; send_from_directory(filename=) became path=; the old names were removed in 2.2

update the call sites; the old names raise TypeError today

Flask 2.2

the JSON provider (app.json) is introduced; a list return becomes JSON; a generator return streams; abort consults app.aborter; Response.autocorrect_location_header becomes False (Werkzeug 2.1)

replace json_encoder and json_decoder with a provider

Flask 2.3

json_encoder, json_decoder, JSON_AS_ASCII, JSON_SORT_KEYS, JSONIFY_PRETTYPRINT_REGULAR and JSONIFY_MIMETYPE removed; Vary: Cookie added when the session is accessed (2.3.2)

move the settings to the provider’s attributes

Werkzeug 1.0

request.is_xhr removed

use a separate API route or an explicit header

Werkzeug 2.3

get_json() answers 415 when the content type is not JSON

send Content-Type: application/json from clients and tests

Werkzeug 3.1

partitioned argument added to set_cookie and delete_cookie

use it for CHIPS cookies

Flask 3.2 (unreleased)

redirect() returns 303 by default instead of 302; RequestContext merged into AppContext; all teardown callbacks run even if one raises; app.query decorator for the HTTP QUERY method

pass code=303 today in Post/Redirect/Get code so behaviour does not change when you upgrade; check tests that assert a 302

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 a dict, or jsonify.

  • Examples that set app.json_encoder, JSON_SORT_KEYS or JSONIFY_PRETTYPRINT_REGULAR, or call send_file(..., attachment_filename=..., cache_timeout=...), fail on a current Flask; use the JSON provider and the download_name and max_age parameters.

  • 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_xhr to 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 or Response; None is the one value that always fails.

  • Use make_response (or build a Response) when you must touch headers, cookies or caching; use a (body, status, headers) tuple for the common case.

  • set_cookie defaults are permissive: set secure, httponly and samesite explicitly, and delete a cookie with the same path and domain.

  • Redirect with url_for; today pass code=303 for Post/Redirect/Get (the default changes from 302 to 303 in Flask 3.2); never follow an unchecked next parameter.

  • Return a dict or a list for JSON and configure the output through the JSON provider (app.json), not through removed config keys; wrap default rather than replacing it; flask-orjson swaps the engine.

  • Stream large or slow responses with generators and stream_with_context; serve files with send_file and send_from_directory, with a configured directory, download_name and max_age, and USE_X_SENDFILE behind a server that supports it.