Hooks & View Decorators
|
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; the examples were executed with those versions. Flask 3.2 is not released yet; the items of its changelog that touch hooks are described as upcoming in What Changed and Where Older Material Is Outdated. Flask gives you two ways to run code around your views without touching every view: hooks, which are functions registered once on the application or a blueprint and called by Flask at fixed points of every request, and view decorators, which are ordinary Python decorators you apply to the views that need them. Hooks are for rules that apply to many or all requests (authenticate the caller, add security headers, release a resource); decorators are for rules that apply to chosen views (this page needs a login, this one is cacheable). The page uses the Bookshelf application of the previous pages. The exact order in which Flask runs everything is explained in the Application & Request Lifecycle page; here the focus is how to use each hook well and how to write decorators that do not break.
The Hooks at a Glance
A hook is registered by decorating a function with a method of the Flask object (or of a Blueprint). Flask itself
does the dispatching, in Flask.full_dispatch_request and Flask.wsgi_app; the request and response objects, the
routing and the context locals come from Werkzeug, so everything a hook receives or returns is a Werkzeug Response
or a value Flask can turn into one.
| Hook | When it runs | What it may do |
|---|---|---|
|
Before the view, after routing, in registration order |
Return |
|
After a response exists (from the view or a handled error), in reverse registration order |
Receive the response, return a response (the same or a new one) |
|
Same moment as |
Receive the response, return a response |
|
When the request context is popped, in reverse registration order |
Release per-request resources; receives the exception or |
|
When the application context is popped, in reverse registration order |
Release resources stored on |
The figure below shows the order for one request that reaches a view, with the places where a short circuit or an exception changes the path.
Every function in the figure can be an async def when Flask is installed with the async extra; see
Async Hooks and Decorators.
before_request: Run Code Before Every View
before_request functions take no arguments. If one returns something other than None, Flask treats it as the
response, does not call the remaining before_request functions or the view, and continues with the response phase.
That short circuit is the standard way to implement authentication, rate limiting, maintenance mode or an
"unsupported media type" check for a whole application or blueprint. The same function is where you load things the
views share (the current user, the request id) into g, so that the views stay free of boilerplate.
The Bookshelf API below requires an API key on every route except the public ones. A function that returns a response
stops the request before any view runs. The key comparison uses hmac.compare_digest, which takes the same time
whatever the position of the first different character, and the demo key is generated at start-up rather than written
in the source.
import hmac
import secrets
from flask import Flask, g, jsonify, request
app = Flask(__name__)
DEMO_KEY = secrets.token_hex(16) # in a real application: read from the environment or a secrets store
API_KEYS = {DEMO_KEY: "alice"}
PUBLIC_ENDPOINTS = {"index", "static"}
@app.before_request
def authenticate():
# Unknown URLs have no endpoint; let routing answer 404 instead of leaking a 401 for every typo.
if request.endpoint is None or request.endpoint in PUBLIC_ENDPOINTS:
return None
supplied = request.headers.get("X-API-Key", "")
for key, user in API_KEYS.items():
if hmac.compare_digest(key, supplied):
g.user = user
return None
return jsonify(error="missing or invalid API key"), 401, {"WWW-Authenticate": "ApiKey"}
@app.get("/")
def index():
return jsonify(name="Bookshelf")
@app.get("/books")
def list_books():
return jsonify(user=g.user, books=["Dune", "Emma"])
client = app.test_client()
print(client.get("/").status_code) # 200, public
print(client.get("/books").status_code) # 401, short-circuited
print(client.get("/nope").status_code) # 404, not 401
print(client.get("/books", headers={"X-API-Key": DEMO_KEY}).get_json())
Source: Flask API: Flask.before_request
A few details are easy to get wrong:
-
request.endpointisNonewhen no route matched. Flask raises the routing error (404, 405, a redirect for a missing trailing slash) only after thebefore_requestfunctions have run, so the hook does run for a request that will end in 404. A guard such asrequest.endpoint.startswith(...)raisesAttributeErrorand turns every unknown URL into a 500. -
The built-in
staticendpoint exists when the application has a static folder; an authentication hook must allow-list it explicitly or the CSS stops loading for anonymous visitors. -
before_requestruns for every request, including preflightOPTIONSrequests answered automatically by Flask. A CORS preflight carries no credentials, so blocking it with a 401 breaks browser clients; allow-listOPTIONSor let a CORS extension answer first. -
An allow-list is safer than a block-list: a new route is protected by default, and forgetting to list it makes it fail closed instead of open. Hooks are also more reliable than decorators for mandatory rules, because a forgotten decorator silently leaves a view open (see Decorator Order).
-
Do slow work lazily. The hook runs for every request, even for views that do not need the data it loads; for expensive resources use the
get_x()pattern onginstead of loading in the hook. -
Hooks are also the place for the work that books written for Flask 1.x did with
before_first_request. That decorator was deprecated in 2.2 and removed in 2.3; do one-time set-up in the application factory.
after_request: Change the Response
after_request functions receive the response object and must return a response, normally the same one after
mutating it. Typical jobs are security headers, a request-time header, CORS headers, cookies and gzip-style
post-processing. Because the response is a Werkzeug Response, its headers, cache_control, vary and
set_cookie helpers are available.
import time
from flask import Flask, g, request
app = Flask(__name__)
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Content-Security-Policy": "default-src 'self'",
}
@app.before_request
def start_timer():
g.started = time.perf_counter()
@app.after_request
def add_headers(response):
for name, value in SECURITY_HEADERS.items():
response.headers.setdefault(name, value) # a view may override a header
elapsed_ms = (time.perf_counter() - g.started) * 1000
response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.1f}"
return response
@app.get("/boom")
def boom():
raise RuntimeError("unexpected")
@app.errorhandler(404)
def not_found(error):
return "no such page", 404
client = app.test_client()
for path in ("/missing", "/boom"):
response = client.get(path)
print(path, response.status_code, response.headers["X-Content-Type-Options"], "Server-Timing" in response.headers)
Source: Flask API: Flask.after_request
Both responses carry the headers: after_request runs for a response produced by a handled error and for the generic
500 page Flask builds when a view raises an unhandled exception. What it does not do is run when the work before the
response phase fails in a way Flask cannot turn into a response, and a function that itself raises replaces the
response with a 500. Keep these functions short and free of I/O that can fail; use teardown_request for work that
must always run and cannot change the response. Further points:
-
Forgetting
return responsemakes Flask raise aTypeErrorbecauseNoneis not a valid response. -
after_requestfunctions run in the reverse of registration order, and a blueprint’s functions run before the application’s. When several functions set the same header with plain assignment, the one registered first runs last and wins. -
A streaming response is already created when the hook runs, but its body is generated later, after the hook and after the teardown functions may have run. Do not rely on the hook seeing the body, and read the lifecycle page on
stream_with_contextbefore readinggorrequestin a generator. -
Setting
Vary: CookieandCache-Control: privateon responses that depend on the session prevents shared caches from storing one user’s page and serving it to another. Flask setsVary: Cookieitself when the session is accessed.
teardown_request and teardown_appcontext: Release Resources
Teardown functions are the "finally" of a request. They run when the context is popped, whether the request succeeded
or failed, receive the exception (or None), and their return value is ignored. Two decorators exist because the two
contexts have different lifetimes: teardown_request runs when the request context is popped (an HTTP request),
teardown_appcontext when the application context is popped. Inside a normal request both run, the request one
first; in a CLI command or a script that only pushes an application context, only teardown_appcontext runs. That is
why the official pattern for a database connection stored on g uses teardown_appcontext: the same get_db() and
close_db() then work in views, in flask commands and in scripts.
from flask import Flask, g
app = Flask(__name__)
events: list[str] = []
class Connection:
"""Stand-in for a database connection."""
def __init__(self):
events.append("open")
def close(self):
events.append("close")
def get_db():
if "db" not in g:
g.db = Connection() # created lazily, once per application context
return g.db
@app.teardown_appcontext
def close_db(exception):
db = g.pop("db", None)
if db is not None:
db.close()
@app.teardown_request
def note_request_teardown(exception):
events.append(f"teardown_request({type(exception).__name__ if exception else None})")
@app.get("/count")
def count():
get_db()
get_db() # second call reuses the connection
return "ok"
@app.get("/fail")
def fail():
get_db()
raise ValueError("view failed")
app.config["PROPAGATE_EXCEPTIONS"] = False
client = app.test_client()
client.get("/count")
print(events)
events.clear()
client.get("/fail")
print(events)
events.clear()
with app.app_context(): # a script or a CLI command: no request context
get_db()
print(events)
Source: Flask: Using SQLite 3 with Flask
The first output line shows open, teardown_request(None) and close; the second shows the same with
teardown_request(ValueError); the third shows only open and close. The rules to remember:
-
Teardown functions always run, even when a
before_requestfunction short-circuited or the view raised, so they must tolerate a half-initialised state (henceg.pop("db", None)). -
A teardown function must not raise. In Flask 3.1.3 an exception in one function propagates and the teardown functions registered before it (which run later) are skipped, so a failing cleanup can leak the resources the others would have released. The Flask 3.2 changelog states that all of them will run even if one raises; write them as if that were already true.
-
The
exceptionargument is the exception that propagated, not one an error handler already handled; for an exception handled byerrorhandlerit isNone. Use it to roll back a transaction rather than to log. -
A teardown function cannot change the response, which has already been built. Anything that must alter the response belongs in
after_request. -
When the test client is used as a context manager (
with app.test_client() as client:), the request context of the last request stays alive until the block ends, so teardown runs late in such tests; the testing page of this section covers it.
after_this_request: One-Off Response Changes
Sometimes only one view (or a helper deep inside it) must change the response, but it does not own the response object
yet. flask.after_this_request registers a function for the current request only; it receives the response and must
return it. The registration is dropped at the end of the request, so it never leaks into other requests, and it is the
reason the decorator can only be used inside a request.
from flask import Flask, after_this_request, jsonify, request
app = Flask(__name__)
def remember_theme(theme: str) -> None:
"""A helper that has no access to the response object."""
@after_this_request
def set_cookie(response):
response.set_cookie("theme", theme, max_age=60 * 60 * 24 * 365, samesite="Lax", httponly=True)
return response
@app.post("/preferences")
def preferences():
theme = request.form.get("theme", "light")
if theme not in {"light", "dark"}:
return jsonify(error="unknown theme"), 400
remember_theme(theme)
return jsonify(theme=theme)
client = app.test_client()
print(client.post("/preferences", data={"theme": "dark"}).headers.get_all("Set-Cookie"))
print(client.post("/preferences", data={"theme": "pink"}).headers.get_all("Set-Cookie"))
Source: Flask API: after_this_request
The cookie is set only for the valid request. Callbacks registered with after_this_request run before the
after_request functions of the blueprint and of the application, in the order they were registered, and apply to
whatever response the request ends with, so a view that returns a redirect() also gets the cookie. The value is
validated before use: the cookie value comes back on later requests and is client-controlled input.
Session data, flashing and signed cookies belong to the sessions page of this section.
Deferred Work: Callbacks After the Response
after_request and after_this_request still run before the response reaches the client. Work that must happen
after it has been sent (cleaning a temporary file that is streamed, closing a resource) uses a different mechanism of
Werkzeug: Response.call_on_close. The WSGI server calls close() on the response iterable once it has finished
sending it, and the registered callbacks run then.
import os
import tempfile
from flask import Flask, Response
app = Flask(__name__)
log: list[str] = []
@app.get("/export")
def export():
handle, path = tempfile.mkstemp(suffix=".csv")
with os.fdopen(handle, "w") as stream:
stream.write("title,author\nDune,Herbert\n")
def generate():
with open(path, "rb") as source:
yield from source
response = Response(generate(), mimetype="text/csv", headers={"Content-Disposition": 'attachment; filename="books.csv"'})
@response.call_on_close
def cleanup(): # no request or application context is active here
os.remove(path)
log.append(f"removed {os.path.basename(path)}")
return response
client = app.test_client()
response = client.get("/export", buffered=False)
print(response.status_code, response.get_data())
response.close() # a WSGI server does this after sending the body
print(len(log), log[0].startswith("removed"))
Source: Werkzeug: Response.call_on_close
The example builds a plain Response around a generator on purpose: a response created by send_file uses
direct_passthrough, and in Werkzeug 3.1.9 get_app_iter returns such a response’s file iterable as it is, without
the wrapper that runs the call_on_close callbacks, so callbacks registered on it do not run (checked with the test
client). Callbacks registered this way run outside the request, so request, g and current_app are not available in them;
capture what they need in a closure, as path is above. Two rules apply to all deferred work. First, it still occupies
the worker until it finishes, so it is for a few milliseconds of cleanup, not for sending e-mail; use a task queue for
that (see the background-tasks page of this section). Second, a thread that needs request or g must receive a copy
of the context with copy_current_request_context, which the lifecycle page shows.
Hooks and Blueprints
Every hook decorator exists on Blueprint too, and a blueprint’s before_request, after_request and teardown
functions run only for the requests that match one of the blueprint’s own routes. The *_app_* variants apply to the
whole application: before_app_request, after_app_request, teardown_app_request, plus app_errorhandler,
app_context_processor and app_url_value_preprocessor.
from flask import Blueprint, Flask, abort, request
app = Flask(__name__)
admin = Blueprint("admin", __name__, url_prefix="/admin")
trace: list[str] = []
@admin.before_request
def only_admin():
trace.append("admin.before_request")
if request.headers.get("X-Role") != "admin": # demonstration only; real code uses real authentication
abort(403)
@admin.before_app_request
def log_everything():
trace.append(f"before_app_request for {request.endpoint}")
@admin.get("/stats")
def stats():
return "stats"
@app.get("/")
def index():
return "home"
app.register_blueprint(admin)
client = app.test_client()
print(client.get("/").status_code, trace); trace.clear()
print(client.get("/admin/stats").status_code, trace); trace.clear()
print(client.get("/admin/stats", headers={"X-Role": "admin"}).status_code, trace)
The admin guard runs only for /admin/stats; the app-wide function runs for both. Two consequences are worth
knowing. A blueprint hook never runs for a URL that matched no route (there is no blueprint), so a security rule that
must also cover unknown URLs has to be an application-level hook. And with nested blueprints the outermost blueprint’s
before_request runs first; the ordering rules are tabulated in the lifecycle page. Blueprint registration is covered
by the Blueprints page of this section.
Writing View Decorators
A view function is an ordinary function, so Python decorators apply. A decorator replaces the view by a wrapper;
Flask registers the wrapper under the endpoint name, which is derived from the function’s __name__. If the wrapper
keeps the name wrapper (or decorated_function), the second decorated view tries to register the same endpoint and
Flask raises an AssertionError. functools.wraps copies the name, docstring and __wrapped__ attribute from the
original, so decorators must always use it.
from functools import wraps
from flask import Flask
app = Flask(__name__)
def without_wraps(view):
def wrapper(*args, **kwargs):
return view(*args, **kwargs)
return wrapper
def with_wraps(view):
@wraps(view)
def wrapper(*args, **kwargs):
return view(*args, **kwargs)
return wrapper
@app.get("/a")
@without_wraps
def first():
return "a"
try:
@app.get("/b")
@without_wraps
def second():
return "b"
except AssertionError as error:
print("AssertionError:", error)
@app.get("/c")
@with_wraps
def third():
return "c"
@app.get("/d")
@with_wraps
def fourth():
return "d"
print(sorted(app.view_functions))
Source: Flask: View Decorators
The first collision message names the endpoint (wrapper). The general shape of every decorator on this page is the
same: an outer function receives the view, an inner function with the same signature (*args, **kwargs) does the
extra work and calls the view, and a decorator that takes arguments adds one more level.
login_required
The classic decorator redirects anonymous users to the login page. It relies on the current user having been loaded
into g.user (or read from session) by a before_request function; the Flaskr tutorial’s auth blueprint uses
this exact structure. The official pattern builds the next parameter from request.url; the version below keeps only
the path and query, and the login view accepts only a local path, because a login page that redirects to whatever
next contains is an open redirect that phishing links abuse.
import secrets
from functools import wraps
from urllib.parse import urlsplit
from flask import Flask, g, redirect, request, session, url_for
app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex(32) # real applications read it from the environment
@app.before_request
def load_user():
g.user = session.get("user")
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if g.user is None:
return redirect(url_for("login", next=request.full_path.rstrip("?")))
return view(*args, **kwargs)
return wrapped
def is_local(target: str) -> bool:
parts = urlsplit(target)
return not parts.scheme and not parts.netloc and target.startswith("/") and not target.startswith("//")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
session["user"] = request.form["user"] # demonstration: no password check here
target = request.args.get("next", "")
return redirect(target if is_local(target) else url_for("shelf"))
return "login form"
@app.route("/shelf") # route() is always the OUTERMOST decorator
@login_required
def shelf():
return f"shelf of {g.user}"
client = app.test_client()
response = client.get("/shelf?page=2")
print(response.status_code, response.headers["Location"])
response = client.post(response.headers["Location"], data={"user": "alice"})
print(response.status_code, response.headers["Location"])
print(client.get("/shelf").get_data(as_text=True))
response = client.post("/login?next=https://evil.example/", data={"user": "bob"})
print(response.headers["Location"]) # falls back to /shelf
In a real application the login view checks a password hash and the session cookie is protected by the secret key;
the authentication page of this section builds this properly with Flask-Login, whose login_required is the same idea
with fresh-login and login_view handling. Do not take the permission decision from the client: the decorator reads
the user from a signed session or a verified token, never from a cookie or header the client can invent.
Decorators with Arguments: roles_required
A decorator that needs configuration is a function that returns the decorator. For authorisation the decision usually differs between the two failures: not logged in (401, or a redirect to the login page) and logged in without the permission (403). Returning 403 for a missing role, instead of redirecting to the login page, avoids a redirect loop.
from functools import wraps
from flask import Flask, abort, g, jsonify
app = Flask(__name__)
USERS = {"alice": {"admin", "editor"}, "bob": {"editor"}}
@app.before_request
def load_roles():
from flask import request
name = request.headers.get("X-Demo-User") # demonstration only; never trust a header like this
g.user = name if name in USERS else None
def roles_required(*roles):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
if g.user is None:
abort(401)
if not set(roles) <= USERS[g.user]:
abort(403)
return view(*args, **kwargs)
return wrapped
return decorator
@app.delete("/books/<int:book_id>")
@roles_required("admin")
def delete_book(book_id):
return jsonify(deleted=book_id)
client = app.test_client()
print(client.delete("/books/1").status_code)
print(client.delete("/books/1", headers={"X-Demo-User": "bob"}).status_code)
print(client.delete("/books/1", headers={"X-Demo-User": "alice"}).get_json())
Source: Flask: View Decorators
Role-based and attribute-based authorisation, and ownership checks, are developed in the authorisation page of this section.
A Caching Decorator
The official page shows a decorator that stores the view’s return value in a cache keyed by the request path. That
version is only correct for responses that depend on nothing but the path. A safer and cheaper first step is to let the
browser and shared caches do the work by setting standard headers; the decorator below does that, with an ETag so a
revalidation costs no body.
from functools import wraps
from flask import Flask, make_response, request
app = Flask(__name__)
def cacheable(max_age: int = 60, *, public: bool = True):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
response = make_response(view(*args, **kwargs))
if request.method == "GET" and response.status_code == 200:
response.cache_control.max_age = max_age
response.cache_control.public = public
response.cache_control.private = not public
response.add_etag()
response.make_conditional(request) # 304 when If-None-Match matches
return response
return wrapped
return decorator
@app.get("/genres")
@cacheable(max_age=3600)
def genres():
return {"genres": ["fiction", "history", "science"]}
client = app.test_client()
first = client.get("/genres")
print(first.status_code, first.headers["Cache-Control"], first.headers["ETag"])
second = client.get("/genres", headers={"If-None-Match": first.headers["ETag"]})
print(second.status_code, len(second.data))
For an in-process or Redis-backed cache the same shape applies (compute a key, return a hit, store a miss), and the key
must include everything the output depends on: the query string, the language, and the user when the page is personal.
A cached page that omits the user from its key shows one reader’s data to the next. The Flask-Caching extension ships a
tested cached decorator with those features, and the extensions page of this section lists it.
A Templating Decorator
The templated decorator from the official patterns lets a view return a dictionary and have the template rendered
for it. It also shows the three useful behaviours of a well-behaved decorator: it keeps the call signature, it passes
non-dictionary return values (such as a redirect) through unchanged, and it has a sensible default (the endpoint name).
from functools import wraps
from flask import Flask, render_template, request
from jinja2 import DictLoader
app = Flask(__name__)
app.jinja_env.loader = DictLoader({ # normally these files live in the templates folder
"index.html": "<h1>{{ title }}</h1>",
"books/detail.html": "<p>{{ book }}</p>",
})
def templated(template=None):
def decorator(view):
@wraps(view)
def wrapped(*args, **kwargs):
name = template
if name is None:
name = f"{request.endpoint.replace('.', '/')}.html"
context = view(*args, **kwargs)
if context is None:
context = {}
elif not isinstance(context, dict):
return context # a redirect, a string, a tuple
return render_template(name, **context)
return wrapped
return decorator
@app.get("/")
@templated()
def index():
return {"title": "Bookshelf"}
@app.get("/book")
@templated("books/detail.html")
def book_detail():
return {"book": "Dune"}
client = app.test_client()
print(client.get("/").get_data(as_text=True))
print(client.get("/book").get_data(as_text=True))
Modern Flask returns a dictionary or list from a view directly as JSON, so a decorator that treats a returned
dictionary as template data conflicts with that built-in behaviour and surprises readers; use it only in a code base
that has chosen it, or prefer the explicit render_template call. Template syntax and the template context are in the
Jinja page of this section.
The Endpoint Decorator
@app.endpoint(name) is not a wrapper: it binds a function to an endpoint name that a rule in the Werkzeug URL map
already uses. It is what lets you add a Rule with custom options to app.url_map and attach the view later.
from flask import Flask
from werkzeug.routing import Rule
app = Flask(__name__)
app.url_map.add(Rule("/", endpoint="index"))
@app.endpoint("index")
def my_index():
return "Hello world"
print(app.test_client().get("/").get_data(as_text=True))
Decorator Order and Other Pitfalls
Decorators are applied from the bottom up, so the one written closest to def wraps first. route() (and get,
post, and so on) registers whatever function it receives; if it is not the outermost decorator it registers the
function without the decorators above it, and the protection silently does nothing.
from functools import wraps
from flask import Flask, abort
app = Flask(__name__)
def deny(view):
@wraps(view)
def wrapped(*args, **kwargs):
abort(403)
return wrapped
@deny # WRONG: applied after route() has registered the bare function
@app.get("/wrong")
def wrong():
return "should be protected"
@app.get("/right") # right: route() is outermost and registers the decorated function
@deny
def right():
return "protected"
client = app.test_client()
print(client.get("/wrong").status_code, client.get("/right").status_code)
Source: Flask: View Decorators
The output is 200 403: the view that looks protected is open. The same rule explains an error found in the flask-smorest
material of one of the books (a @blp.response placed above @blp.route, where route must be outermost); the API
documentation page of this section returns to it.
| Pitfall | What to do |
|---|---|
Missing |
Always use it; otherwise endpoints collide and |
|
Keep |
Applying the decorator per view for a rule that must hold everywhere |
Use a |
Hook that raises for unknown URLs ( |
Guard the attribute, or return early |
An |
Always |
A teardown function that raises, or that assumes a resource exists |
Make it idempotent and tolerant of a missing resource |
Using |
Capture values beforehand, or pass a copied context |
Class-based views |
Decorators go in the view’s |
Source: Flask: Class-based Views
Several decorators on one view run in the order they are listed, from the outermost to the innermost, so put
authentication before authorisation and both before caching: a cache decorator placed above login_required would
serve a protected page from the cache to an anonymous visitor.
Async Hooks and Decorators
With Flask installed with the async extra (pip install "flask[async]"), views, error handlers, before_request,
after_request and teardown functions may be coroutine functions. Flask runs each of them in an event loop it starts
in the worker’s thread and waits for the result, so one worker still serves one request at a time: async is not
faster, it allows await inside the function, and background tasks created with asyncio.create_task are cancelled
when the function returns.
A decorator is where async support is most often lost. A plain def wrapper that calls an async def view receives a
coroutine object instead of a response, and Flask answers with a 500. The fix recommended by the official async page is
to call the view through current_app.ensure_sync, which returns a plain function unchanged and wraps a coroutine
function so that it is run to completion. The wrapper then works for both kinds of view and stays a normal def.
import asyncio
from functools import wraps
from flask import Flask, current_app, g
app = Flask(__name__)
@app.before_request
async def prepare(): # an async hook, run through ensure_sync by Flask
await asyncio.sleep(0)
g.audit = []
def audited(view):
@wraps(view)
def wrapped(*args, **kwargs):
g.audit.append(view.__name__)
return current_app.ensure_sync(view)(*args, **kwargs) # works for def and async def
return wrapped
def audited_naive(view):
@wraps(view)
def wrapped(*args, **kwargs):
return view(*args, **kwargs) # returns a coroutine for async views
return wrapped
@app.get("/sync")
@audited
def sync_view():
return f"sync {g.audit}"
@app.get("/async")
@audited
async def async_view():
await asyncio.sleep(0)
return f"async {g.audit}"
@app.get("/naive")
@audited_naive
async def naive_view():
return "never reached"
client = app.test_client()
print(client.get("/sync").get_data(as_text=True))
print(client.get("/async").get_data(as_text=True))
print(client.get("/naive").status_code)
Source: Flask: Using async and await
The same applies to extensions: one written before Flask 2.0 that provides view decorators may not await the function
it wraps, so an async def view under it fails until the extension adds ensure_sync support; check its changelog.
The async page of this section explains the event loop, gevent and Quart in depth.
What Changed and Where Older Material Is Outdated
| Version | Change | What to do |
|---|---|---|
2.0 |
|
Wrap views in decorators with |
2.2 and 2.3 |
|
Do one-time set-up in the application factory; use the lazy |
2.3 |
Setup methods such as |
Register all hooks while building the application |
3.0 |
The context stack objects are gone |
Store per-request data on |
Source: Flask: Changes
Older books and blog posts show several patterns that no longer work. @app.before_first_request is gone, as above.
request.is_xhr was removed in Werkzeug 1.0, so a before_request hook that branched on it must now read an explicit
header or use separate API routes. Code that stores data on _request_ctx_stack.top or _app_ctx_stack.top must use
g. Decorators written without functools.wraps collide on the endpoint name, as shown above; adding wraps is the
fix, not passing endpoint= to every route().
Flask 3.2 is unreleased as of 2026-10-07, so the following is subject to change. Its changelog states that all teardown
callbacks run even if one raises, so every teardown function should be safe to run after another has failed. It also
merges the request context into the application context, which affects code that imports flask.ctx.RequestContext,
not the decorators on this page; and it lets template_filter, template_test and template_global be used without
parentheses. should_ignore_error is deprecated in favour of handling errors in teardown functions.
Source: Flask: Changes (development version)
Security Notes
-
Prefer a
before_requestallow-list to per-view decorators for authentication, and keep decorators for finer authorisation. A forgotten decorator is an open endpoint; a forgotten allow-list entry is a visible failure. -
Compare secrets with
hmac.compare_digest; never put keys in the source or in a response header you log. -
Validate any redirect target (
next) and accept only local paths. -
Do not store the request, the user’s password or tokens on
gin a way that teardown or logging hooks print. -
Do not run the interactive debugger or the reloader in production; hooks are not a substitute for an error handler and the debugger exposes code execution to anyone who reaches it.
-
Add security headers in an
after_requesthook only if the proxy does not already add them, and usesetdefaultso a view can make an exception. -
Keep hooks fast: work done in a
before_requestorafter_requestfunction is paid by every request.
Summary
Use hooks for rules that apply to whole applications or blueprints: authenticate in before_request with an allow-list,
adjust headers in after_request, and release resources in teardown_appcontext (or teardown_request for work that
is specific to a request). Use after_this_request to change only one response from deep inside a view and
call_on_close for cleanup after the body has been sent. Use decorators for per-view rules, always with
functools.wraps, always below route(), and call the view through ensure_sync so that they keep working with
async def views.