Signals
|
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, Blinker 1.9.0 and Python 3.13, checked on 2026-10-07; the examples were executed with those versions. Flask 3.2 is not released yet; as far as its changelog shows, it does not add or remove any signal, but it merges the application and request contexts, which changes the wording (not the order) of the lifecycle the signals are tied to, as described in What Changed and Where Older Material Is Outdated. A signal is a small publish/subscribe object: some code sends it when something happens, and any number of receivers (also called subscribers) registered earlier are called. Flask does not implement signals itself. They come from the Blinker library, which Flask has required since version 2.3, and Flask only creates the ten core signals and sends them from the places listed below. The page uses the Bookshelf application of the previous pages and builds on the order of events explained in Application & Request Lifecycle.
What Signals Are For
The official documentation puts it this way: many signals mirror a decorator-based callback with a similar name
(request_started is similar to before_request), and the advantage of signals is that they can be subscribed to
temporarily and cannot directly affect the application. A signal receiver has no way to short-circuit the request,
replace the response or choose the error page; its return value is ignored by Flask. That makes signals the right tool
for observation (metrics, audit logs, cache invalidation, tests) and the wrong tool for control. Control flow belongs to
hooks and error handlers, covered in Hooks & View Decorators and
Error Handling.
| Need | Use | Why |
|---|---|---|
Reject a request, redirect, add a header to every response |
A hook ( |
Hooks can return a response or change it; signal receivers cannot |
Count requests, record timings, write an audit line |
A signal ( |
Observation only; can be attached and removed at run time |
Check in a test which template rendered, or which message was flashed |
A signal with |
Nothing to change in the application code, and the receiver disappears after the |
Tell other parts of your own application "a book was added" |
A custom signal |
The sender does not need to know who listens (loose coupling) |
Run something that must happen, in order, for every request |
A hook |
The order of receivers is not defined, and a hook is guaranteed to be called |
Source: Flask: Signals
Blinker in Five Minutes
Blinker is a small Python library (2024-11-08 for 1.9.0) with one central class, blinker.Signal, and a named subclass,
blinker.NamedSignal, which adds a name. The pieces you use every day are in the table.
| Name | Meaning |
|---|---|
|
Register |
|
Decorator form of |
|
Context manager: connects (strongly) on entry, disconnects on exit |
|
Remove a connection |
|
Context manager that silences the signal inside the block |
|
Call every matching receiver now, in the current thread, and return a list of |
|
Coroutine that awaits every matching receiver (Blinker 1.7 and newer) |
|
Ask whether anyone listens, or iterate the receivers, for a sender |
|
The "any sender" marker, the default for |
|
A private collection of named signals; asking twice for the same name returns the same object |
|
Meta signals sent after each |
Source: Blinker: Documentation
Four behaviours matter in practice and are shown in the examples that follow: a receiver receives the sender as its
first positional argument; all other data arrives as keyword arguments, so a receiver should end with **extra; the
order in which receivers are called is not defined (Blinker stores them in a set); and an exception raised by a
receiver is not caught, it propagates to the code that called send, which for Flask’s own signals means into the
request.
The Core Signals
Flask defines ten signals in flask.signals and re-exports them from the top-level flask package. For every one of
them the sender is the application object (not current_app, the proxy: the real Flask instance).
| Signal | Keyword arguments | Sent when | Notes |
|---|---|---|---|
|
none |
An application context is pushed |
Sent for requests, |
|
none |
At the start of |
|
|
|
Just before |
Receivers see the context after context processors ran, and can inspect it |
|
|
Just after the template was rendered |
For a streamed template, after the last chunk was generated |
|
|
At the end of |
Lets a test see messages without reading the session |
|
|
After |
Also sent for error responses made by an error handler |
|
|
An unhandled exception happened, or one was raised from an error handler |
Not sent for |
|
|
The request context is popped, after the |
Always sent, even when a request failed; |
|
|
The application context is torn down, after the |
Always sent, including for CLI commands and scripts |
|
none |
The application context has been popped |
The last event of a request |
Source: Flask: API, Signals
The figure shows where each of them fires during one request, interleaved with the hooks they resemble. The diagram describes Flask 3.1; the order matches the lifecycle page.
The first example connects receivers to several core signals, sends a request through the test client and records what happened. It uses a small Bookshelf application that later examples extend.
import secrets
import time
from flask import (
Flask, appcontext_popped, appcontext_pushed, appcontext_tearing_down,
flash, g, jsonify, render_template_string, request, request_finished,
request_started, request_tearing_down,
)
app = Flask(__name__)
app.config.update(SECRET_KEY=secrets.token_hex(), TESTING=False)
BOOKS = {1: {"id": 1, "title": "Dune"}, 2: {"id": 2, "title": "Emma"}}
events: list[str] = []
@app.get("/books")
def book_list():
flash("Listing books", "info")
return jsonify(list(BOOKS.values()))
@app.get("/books/<int:book_id>/page")
def book_page(book_id):
book = BOOKS.get(book_id) or ("missing", 404)
return render_template_string("<h1>{{ book['title'] }}</h1>", book=book)
@request_started.connect_via(app)
def on_started(sender, **extra):
g.started = time.perf_counter()
events.append(f"request_started {request.method} {request.path}")
@request_finished.connect_via(app)
def on_finished(sender, response, **extra):
elapsed_ms = (time.perf_counter() - g.started) * 1000
events.append(f"request_finished {response.status_code}")
assert elapsed_ms >= 0
@request_tearing_down.connect_via(app)
def on_request_down(sender, exc=None, **extra):
events.append(f"request_tearing_down exc={exc!r}")
@appcontext_tearing_down.connect_via(app)
def on_appctx_down(sender, exc=None, **extra):
events.append("appcontext_tearing_down")
appcontext_pushed.connect(lambda sender, **kw: events.append("appcontext_pushed"), app, weak=False)
appcontext_popped.connect(lambda sender, **kw: events.append("appcontext_popped"), app, weak=False)
reply = app.test_client().get("/books")
assert reply.status_code == 200
assert events == [
"appcontext_pushed",
"request_started GET /books",
"request_finished 200",
"request_tearing_down exc=None",
"appcontext_tearing_down",
"appcontext_popped",
]
Source: Flask: API, Signals
Two details of that example are deliberate. Every receiver accepts **extra, because Flask may add keyword arguments
to a signal in a later release and a receiver without it would then fail with a TypeError in the middle of a request.
And every receiver is connected to app: when no sender is given the receiver is connected to ANY, which means
every Flask application in the process, including the ones created by other libraries or tests. The official
documentation advises always passing a sender, and especially so in extensions.
Connecting and Disconnecting Receivers
There are three ways to subscribe, and they differ in lifetime.
-
signal.connect(receiver, sender)subscribes until you calldisconnect, or until the receiver is garbage collected, becauseweak=Trueis the default forconnect. -
@signal.connect_via(sender)is the decorator form. It exists for module-level functions that live as long as the module, and it defaults toweak=False, so a receiver defined inside a function stays connected. -
with signal.connected_to(receiver, sender):connects strongly for the duration of the block and disconnects on exit, even when the block raises. It is meant for tests and for short, scoped observation.
The weak flag is the most common source of "my receiver is never called". A weak connection holds a weakref to the
receiver; a lambda, a nested function or a bound method of a short-lived object has no other reference and is removed
as soon as the creating scope ends.
from blinker import Namespace
demo = Namespace()
pinged = demo.signal("pinged")
calls: list[str] = []
def register_weak():
def receiver(sender, **extra):
calls.append("weak receiver")
pinged.connect(receiver, weak=True) # only the signal knows it, through a weakref
def register_strong():
def receiver(sender, **extra):
calls.append("strong receiver")
pinged.connect(receiver, weak=False) # the signal keeps the function alive
register_weak()
register_strong()
import gc
gc.collect()
pinged.send("bookshelf")
assert calls == ["strong receiver"] # the weak one vanished with its scope
# connect_via and connected_to: lifetime and sender filtering
seen = []
@pinged.connect_via("shop")
def only_for_shop(sender, **extra):
seen.append(("via", sender))
with pinged.connected_to(lambda sender, **kw: seen.append(("with", sender)), "library"):
pinged.send("shop")
pinged.send("library")
pinged.send("library") # the connected_to receiver is gone now
assert seen == [("via", "shop"), ("with", "library")]
assert pinged.has_receivers_for("shop") and pinged.has_receivers_for("library")
Source: Blinker: Signal.connect
The senders in that example are strings only to keep the demonstration short. Blinker compares senders by identity, so for Flask the sender is the app object, for an extension it is usually the extension instance or the model class, and a string literal is rarely a good choice in real code.
Two more points about subscribing:
-
disconnect(receiver, sender)must be called with the same receiver object and sender that were used to connect. A lambda created inline cannot be disconnected afterwards unless you stored it in a variable. -
Signal.muted()silences a signal inside awithblock. It is useful in a test that triggers many renders but checks only one, and it affects all receivers of that signal.
Receiver Errors and Return Values
Signal.send returns a list of (receiver, return value) tuples, which is how custom signals can collect answers (for
example "which plug-ins want to veto this?"). Flask ignores the return values of its own signals. It also does not
catch exceptions: a receiver for request_started that raises makes the request fail with a 500 response, and a
receiver for request_finished that raises does the same after the view and the after_request hooks have already
run. Keep receivers short and defensive, move slow or failure-prone work (sending e-mail, calling a remote service) to a
task queue, and log rather than raise.
from flask import request_started
failing = Flask("failing")
failing.config.update(SECRET_KEY=secrets.token_hex())
@failing.get("/")
def home():
return "ok"
def explode(sender, **extra):
raise RuntimeError("receiver bug")
with request_started.connected_to(explode, failing):
assert failing.test_client().get("/").status_code == 500 # the signal broke the request
assert failing.test_client().get("/").status_code == 200 # connected_to has disconnected it
Source: Blinker: Signal.send
Custom Signals with a Namespace
You can create signals for your own application. The official recommendation is a named signal in a Namespace, which
keeps the application’s signals apart from Flask’s and from every other library’s. Do not use blinker.signal(name)
for this: it uses the one process-wide default namespace, so two libraries that choose the name "book-added" would
share one signal. The Bookshelf below announces new and removed books and lets independent parts react.
from blinker import Namespace
from flask import current_app
bookshelf_signals = Namespace()
book_added = bookshelf_signals.signal("book-added", doc="Sent after a book was stored. Arguments: book.")
book_removed = bookshelf_signals.signal("book-removed")
assert book_added.name == "book-added"
assert bookshelf_signals.signal("book-added") is book_added # same name, same object
shelf = Flask("shelf")
shelf.config.update(SECRET_KEY=secrets.token_hex())
audit_log: list[str] = []
cache = {"all-books": ["stale"]}
catalog: dict[int, dict] = {}
def add_book(title: str) -> dict:
book = {"id": len(catalog) + 1, "title": title}
catalog[book["id"]] = book
# The real application object is the sender, never the current_app proxy.
results = book_added.send(current_app._get_current_object(), book=book)
return {"book": book, "receivers": len(results)}
@book_added.connect_via(shelf)
def audit(sender, book, **extra):
audit_log.append(f"added {book['id']}: {book['title']}")
@book_added.connect_via(shelf)
def invalidate_cache(sender, book, **extra):
cache.pop("all-books", None)
@shelf.post("/books/<title>")
def create_book(title):
return add_book(title), 201
reply = shelf.test_client().post("/books/Dune")
assert reply.status_code == 201 and reply.get_json()["receivers"] == 2
assert audit_log == ["added 1: Dune"] and "all-books" not in cache
# A signal without receivers costs almost nothing; skip expensive payloads with has_receivers_for.
assert book_removed.has_receivers_for(shelf) is False
Source: Flask: Signals, Creating Signals
Rules for good custom signals:
-
Pick a meaningful sender. An object that emits the signal passes
self; a free function passescurrent_app._get_current_object(). The official documentation says never to passcurrent_appitself, because it is aLocalProxyand receivers connected to the real app object would not match it. -
Pass data as keyword arguments and document them (the
doc=argument ofNamespace.signalis shown byhelp()). Receivers should accept**extraso you can add arguments later without breaking them. -
Send after the work is committed. A
book_addedsent beforedb.session.commit()makes receivers act on data that may still be rolled back. Receivers must not assume a database transaction is open. -
Remember that
sendis synchronous. A slow receiver slows the sender. A signal is not a message queue: nothing is persisted, nothing is retried and a process restart forgets every connection. For work that must survive a crash use a task queue or a broker; see Messaging and Scheduling. -
Register receivers at import or in
init_app, not inside a view. Connecting in a request would add one more receiver on every request.
Signals and the Request Context
Receivers of request_started, request_finished, the template signals and message_flashed are called inside the
request, so request, g and session work in them, and values stored on g in request_started are still there in
request_finished (the first example relied on that). The tearing-down signals are different: they fire while the
context is being unwound, exc tells you whether the request failed, and the documentation notes that receivers of
request_tearing_down and appcontext_tearing_down currently run after the regular teardown functions but that you
must not rely on that order. Use teardown functions for releasing resources and the signals for observation.
unhandled = Flask("unhandled")
unhandled.config.update(SECRET_KEY=secrets.token_hex(), PROPAGATE_EXCEPTIONS=False)
trace: list[str] = []
@unhandled.get("/boom")
def boom():
raise ValueError("unexpected")
@unhandled.get("/gone")
def gone():
from flask import abort
abort(404)
from flask import got_request_exception, request_tearing_down
got_request_exception.connect(lambda s, exception, **kw: trace.append(f"exception {type(exception).__name__}"), unhandled, weak=False)
request_tearing_down.connect(lambda s, exc=None, **kw: trace.append(f"teardown {type(exc).__name__}"), unhandled, weak=False)
client = unhandled.test_client()
assert client.get("/boom").status_code == 500
assert client.get("/gone").status_code == 404
assert trace == ["exception ValueError", "teardown ValueError", "teardown NoneType"]
The 404 raised by abort is an HTTPException that Flask turns into a response, so got_request_exception is not
sent for it, and exc in the teardown signal is None. The ValueError was unhandled, so both signals saw it. In
production, got_request_exception is the hook-free place to forward unhandled errors to a monitoring service; the
mechanics of error pages are in Error Handling.
Async Receivers
Since Flask 2.3 (changelog: "Signals support async subscriber functions") a receiver of a core Flask signal can be an
async def function. Flask passes _async_wrapper=app.ensure_sync when it sends core signals, so Blinker turns the
coroutine function into a synchronous call with the same mechanism that runs async def views. Two consequences follow
from that. First, the async extra of Flask (pip install "flask[async]", which installs asgiref) must be present,
otherwise the call raises a RuntimeError asking for it. Second, the receiver runs to completion before the signal
returns, so an async receiver does not make the request concurrent; it only lets the receiver use await. The page on async views explains ensure_sync and why async views are not faster; the
section’s page on async, gevent and Quart covers deployment choices.
For custom signals the choice is yours. Plain send() accepts an _async_wrapper and runs coroutine receivers through
it; calling await signal.send_async(sender, ...) from async code awaits coroutine receivers directly and accepts a
_sync_wrapper to run plain functions (for example in a thread). If you send to a coroutine receiver without a
wrapper, Blinker raises RuntimeError("Cannot send to a coroutine function.").
import asyncio
from flask import message_flashed
asyncio_app = Flask("asyncio_app")
asyncio_app.config.update(SECRET_KEY=secrets.token_hex())
flashed: list[tuple[str, str]] = []
@asyncio_app.get("/flash")
def do_flash():
flash("Saved", "success")
return "done"
@message_flashed.connect_via(asyncio_app)
async def record_flash(sender, message, category, **extra):
await asyncio.sleep(0) # any await works inside the receiver
flashed.append((category, message))
assert asyncio_app.test_client().get("/flash").status_code == 200
assert flashed == [("success", "Saved")]
# A custom signal: send() needs a wrapper for coroutines, send_async() awaits them directly.
price_changed = bookshelf_signals.signal("price-changed")
log: list[str] = []
@price_changed.connect_via("shop")
async def notify(sender, title, **extra):
log.append(f"async {title}")
@price_changed.connect_via("shop")
def count(sender, title, **extra):
log.append(f"sync {title}")
try:
price_changed.send("shop", title="Dune")
except RuntimeError as error:
assert "coroutine" in str(error) # no _async_wrapper, so the coroutine receiver is refused
log.clear()
def run_in_thread(func):
async def wrapper(*args, **kwargs):
return await asyncio.to_thread(func, *args, **kwargs)
return wrapper
results = asyncio.run(price_changed.send_async("shop", _sync_wrapper=run_in_thread, title="Dune"))
assert len(results) == 2 and sorted(log) == ["async Dune", "sync Dune"]
Source: Blinker: Signal.send_async
Receivers connected as a method of a class, or as a function inside a larger module, work the same way. Keep async receivers short: the request waits for them.
Using Signals in Tests
Signals shine in tests because they let a test look inside a request without parsing HTML or touching the session. The
official pattern records template_rendered; with connected_to the receiver is removed even if the assertion
fails, and no try/finally is needed.
from contextlib import contextmanager
from flask import template_rendered
pages = Flask("pages")
pages.config.update(SECRET_KEY=secrets.token_hex())
@pages.get("/")
def index():
flash("Welcome", "info")
return render_template_string("{% for b in books %}<li>{{ b }}</li>{% endfor %}", books=["Dune", "Emma"])
@contextmanager
def captured_templates(app):
recorded = []
def record(sender, template, context, **extra):
recorded.append((template, context))
with template_rendered.connected_to(record, app):
yield recorded
@contextmanager
def captured_flashes(app):
recorded = []
def record(sender, message, category, **extra):
recorded.append((category, message))
with message_flashed.connected_to(record, app):
yield recorded
with captured_templates(pages) as templates, captured_flashes(pages) as flashes:
reply = pages.test_client().get("/")
assert reply.status_code == 200
assert len(templates) == 1
template, context = templates[0]
assert template.name is None # a string template has no name; file templates do
assert context["books"] == ["Dune", "Emma"]
assert flashes == [("info", "Welcome")]
Tips for tests:
-
Connect to the specific app object created by your factory (
create_app()), never toANY, or receivers of one test will see requests of the next one. -
Use
before_render_templateto assert on the context before rendering andtemplate_renderedto assert it rendered at all; a template that raises an error never reachestemplate_rendered. -
Use
message_flashedto assert on flash messages without following redirects and reading the session. The testing page of this section covers the test client and fixtures around this. -
Use
got_request_exception.connected_to(...)together withapp.config["PROPAGATE_EXCEPTIONS"] = Falseto assert that an unhandled error was reported, instead of letting the test client re-raise it. -
Use
Signal.muted()to switch off noisy receivers (metrics, audit) in a test that does not care about them.
Signals Versus Hooks and Other Mechanisms
| Aspect | Hooks and handlers | Signals |
|---|---|---|
Can change the request or response |
Yes (return a response, edit the response, raise) |
No (return values ignored) |
Order of execution |
Defined and documented |
Not defined |
Registered with |
|
|
Scope |
App or blueprint |
By sender (the app object for core signals) |
Can be added and removed while running |
Practically no |
Yes, by design |
Typical uses |
Authentication, headers, database sessions, error pages |
Metrics, audit, tests, decoupled notifications |
Failure of the callback |
Becomes a request error |
Also becomes a request error (not isolated), unless you catch it |
Source: Flask: Signals
Other mechanisms cover neighbouring needs: the standard logging module for logs; teardown functions for releasing
resources; Flask.teardown_appcontext for cleanups that must also run in CLI commands; middleware for work around the
WSGI call; SQLAlchemy events for model changes (Flask-SQLAlchemy can additionally send models_committed and
before_models_committed when SQLALCHEMY_TRACK_MODIFICATIONS is on, which has measurable overhead and is off by
default); and Flask-Login’s user_logged_in family, which is a set of Blinker signals built the same way. Extensions
that ship their own signals use a private Namespace, which is the model for yours.
Pitfalls
-
Forgetting the sender connects the receiver to every app in the process. Always pass the app.
-
Weak references drop lambdas and nested functions; use
connect_via,connected_toorweak=False. -
Passing
current_appas the sender of a custom signal; usecurrent_app._get_current_object(). -
A receiver without a catch-all keyword argument (
**extra) breaks when Flask or a library adds a keyword argument. -
Relying on order. Receivers are called in an undefined order; if order matters, call functions explicitly.
-
Raising in receivers turns an observer into a source of 500 errors.
-
Heavy work in receivers blocks the request; hand it to a queue.
-
Signals for control flow. If a receiver must stop a request or change a response, you need a hook.
-
Connecting inside views multiplies receivers; connect once at start-up.
-
Using signals across processes. A signal reaches only receivers in the same process; with several Gunicorn workers each worker has its own receivers and its own state. Use Redis or a broker for cross-process notifications (see Redis).
-
Treating signals as security controls. Authorization must not depend on a receiver being connected.
What Changed and Where Older Material Is Outdated
| Version | Change |
|---|---|
Flask 0.6 to 2.2 |
Signals were optional: Flask used Blinker only if it was installed and otherwise supplied a fake namespace whose
|
Flask 2.3 |
Signals are always available: Blinker (1.6.2 or newer at the time) is a required dependency; |
Flask 3.0 |
Previously deprecated code was removed; |
Blinker 1.7 and newer |
|
Flask 3.1 |
Requires Blinker 1.9 or newer. |
Flask 3.2 (unreleased) |
No signal is added or removed in the changelog checked on 2026-10-07. The application and request contexts are merged
and |
Source: Flask: Changes
The Flask Framework Cookbook covers Blinker in its "Additional Tips and Tricks" material; the third edition
targets Flask 2.2.3 and the second edition Flask 1.0.2, that is, versions from before Flask 2.3 in which Blinker was
an optional dependency. Read that material with these adjustments. Blinker is now always installed, so the instruction
to install it first and any signals_available check are obsolete (the attribute no longer exists in Flask 3.1).
Receivers of core signals may now be async def functions. Signals created with blinker.signal("name") still work,
but prefer your own Namespace for the reasons given in Custom Signals with a Namespace. Finally, treat
signals as in-process observation, as the official documentation does; work that must be durable or run in another
process belongs in a task queue (see the page on background jobs, Celery and e-mail of this section).
Summary
-
Signals are Blinker publish/subscribe objects; Flask defines ten of them and sends each with the app as sender.
-
Receivers observe; they cannot change a request. Use hooks for control.
-
Subscribe with
connect,connect_viaorconnected_to; mind weak references; always pass the sender and**extra. -
Create your own signals in a private
Namespaceand send them after committing the work. -
Async receivers need
flask[async];send_asyncis for custom signals sent from async code. -
In tests,
connected_towithtemplate_rendered,message_flashedorgot_request_exceptiongives you a view inside the request.