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_request

Before the view, after routing, in registration order

Return None to continue; return a response to skip the view (short-circuit)

after_request

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)

after_this_request

Same moment as after_request, but only for the current request, and registered from inside a view

Receive the response, return a response

teardown_request

When the request context is popped, in reverse registration order

Release per-request resources; receives the exception or None; the return value is ignored

teardown_appcontext

When the application context is popped, in reverse registration order

Release resources stored on g; runs for requests, CLI commands and scripts alike

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.

flowchart TD A([Request arrives]) --> B[url_value_preprocessor functions] B --> C[before_request functions, app first, then blueprint] C -->|a function returns a response| F C -->|all return None| D[View function] D -->|returns a value| F[Response created] D -->|raises an exception| E[Error handler or generic 500] E --> F F --> G[after_this_request functions] G --> H[after_request functions, blueprint first, then app] H --> I[Session saved, response sent to the server] I --> J[teardown_request functions] J --> K[teardown_appcontext functions] K --> L([Response closed, call_on_close callbacks run])
Hook order for one request: a short circuit skips only the view, and the teardown functions always run

Source: Flask: Application Structure and Lifecycle

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

A few details are easy to get wrong:

  • request.endpoint is None when no route matched. Flask raises the routing error (404, 405, a redirect for a missing trailing slash) only after the before_request functions have run, so the hook does run for a request that will end in 404. A guard such as request.endpoint.startswith(...) raises AttributeError and turns every unknown URL into a 500.

  • The built-in static endpoint 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_request runs for every request, including preflight OPTIONS requests answered automatically by Flask. A CORS preflight carries no credentials, so blocking it with a 401 breaks browser clients; allow-list OPTIONS or 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 on g instead 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)

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 response makes Flask raise a TypeError because None is not a valid response.

  • after_request functions 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_context before reading g or request in a generator.

  • Setting Vary: Cookie and Cache-Control: private on responses that depend on the session prevents shared caches from storing one user’s page and serving it to another. Flask sets Vary: Cookie itself 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)

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_request function short-circuited or the view raised, so they must tolerate a half-initialised state (hence g.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 exception argument is the exception that propagated, not one an error handler already handled; for an exception handled by errorhandler it is None. 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"))

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

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

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

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)

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 functools.wraps

Always use it; otherwise endpoints collide and url_for and error messages show the wrong name

route() not outermost

Keep route() first, then the access-control decorator, then convenience decorators

Applying the decorator per view for a rule that must hold everywhere

Use a before_request hook with an allow-list; keep decorators for rules that differ per view

Hook that raises for unknown URLs (request.endpoint is None)

Guard the attribute, or return early

An after_request that forgets to return the response

Always return response

A teardown function that raises, or that assumes a resource exists

Make it idempotent and tolerant of a missing resource

Using request, g or current_app in a call_on_close callback or a thread

Capture values beforehand, or pass a copied context

Class-based views

Decorators go in the view’s decorators list (see the class-based views page), because as_view applies them to the generated function

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)

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

async def views and hooks supported with flask[async]; Flask.ensure_sync added

Wrap views in decorators with current_app.ensure_sync

2.2 and 2.3

before_first_request deprecated, then removed in 2.3, together with before_app_first_request on blueprints

Do one-time set-up in the application factory; use the lazy get_x() pattern on g for per-request resources

2.3

Setup methods such as route, before_request and register_blueprint raise an error when called after the first request has been handled

Register all hooks while building the application

3.0

The context stack objects are gone

Store per-request data on g, not on a context object

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_request allow-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 g in 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_request hook only if the proxy does not already add them, and use setdefault so a view can make an exception.

  • Keep hooks fast: work done in a before_request or after_request function 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.