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 Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text
says so. Every example was run on that stack in a scratch project on SQLite, with the Django test runner, the
runserver development server and uvicorn; none of them needs PostgreSQL or Redis. Signals are Django’s in-process
publish/subscribe mechanism: a sender announces that something happened, and any number of receivers registered
elsewhere are called synchronously (or, with asend(), asynchronously) in the same process. The framework uses them for
model saves, request handling, logins, migrations and tasks, and the Bookshelf application (the catalog app built
across this section) uses them for a handful of small jobs: creating a profile for every new user, keeping a search
column current, logging logins, creating a default group after migrate, and announcing a borrowed book. The page
also explains the part the official documentation puts in a warning box: signals look like loose coupling but make code
hard to follow, so they are the last tool to reach for, not the first.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
React to something Django did |
Connect a receiver to the built-in signal; always accept |
|
Register receivers |
Put them in |
|
React to one model only |
Pass |
|
Avoid double registration |
Pass |
|
Side effects after a save |
|
|
Announce your own event |
|
|
Run receivers in an async view |
|
|
Test a receiver |
Trigger the signal’s cause and assert the effect; clean up what you connect. |
|
Decide whether to use a signal at all |
Prefer an explicit call, a model method, a manager, or middleware. |
The dispatcher and its vocabulary
The signal dispatcher lives in django.dispatch. A signal is an instance of django.dispatch.Signal; a receiver is a
callable registered on it; the sender is whatever object the code that fires the signal passes as sender, typically a
class (for model signals, the model class) so that receivers can filter on it. Every signal call has the same shape: a
mandatory sender plus any keyword arguments the signal documents. Receivers are called one at a time, in the order
they were registered, and each call’s return value is collected.
The dispatcher only exists inside one Python process. A signal is not a message queue: nothing is persisted, nothing reaches another server, and a receiver that is slow slows the sender down. Work that must outlive the request or run elsewhere belongs in the tasks framework or Celery, which this section covers on its own pages (a receiver may enqueue such work, see Model signals).
Source: Signals
Receiver functions and @receiver
A receiver is any function or method. It is called with the signal itself as signal, the sender as sender, and the
signal’s arguments as keywords. The documentation is firm about the signature: every receiver must accept sender and
wildcard keyword arguments, because a signal may gain arguments in a future release and a receiver written as
my_receiver(sender) raises a TypeError the moment one is added. The same holds today for any signal that sends extra
keywords.
def my_receiver(sender, **kwargs):
print("Request finished!")
Source: Receiver functions
There are two ways to connect a receiver: call Signal.connect(receiver, sender=None, weak=True, dispatch_uid=None),
or decorate the function with django.dispatch.receiver(signal, **kwargs). The decorator accepts one signal or a list
of signals and passes the remaining keyword arguments (sender, weak, dispatch_uid) on to connect().
import logging
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@receiver(user_logged_in)
def note_login(sender, request, user, **kwargs):
logger.info("login %s", user.get_username())
@receiver(user_login_failed)
def note_failed_login(sender, credentials, request=None, **kwargs):
logger.warning("failed login for %s", credentials.get("username"))
from django.core.signals import request_finished
request_finished.connect(note_login_for_demo) # same effect as the decorator
Source: Connecting receiver functions
A test in the scratch project logs in with self.client.login() and asserts the two log records
(login grace, then failed login for grace), so both receivers were exercised. The credentials dictionary sent by
user_login_failed has the values of keys that look sensitive (password, token, key, secret and similar names)
replaced with asterisks by Django before the signal is sent, but the source documents that cleaning as not
comprehensive, so a receiver should log only the username, as above; see
Authentication for the full login flow.
Weak references: why a local receiver can silently vanish
By default Django stores receivers as weak references, so that a receiver does not keep its module or object alive.
The consequence is that a function defined inside another function (a closure, a lambda, a nested helper in a test) can
be garbage-collected and quietly stop receiving the signal. Pass weak=False to connect() to keep a strong reference.
The scratch project confirms the failure mode: connecting a nested function and then forcing a garbage collection leaves
the signal with no receivers, and the next send() returns an empty result.
from django.dispatch import Signal
sig = Signal()
def connect_local():
def local(sender, **kwargs):
print("never printed once collected")
sig.connect(local) # weak=True by default: nothing else refers to local
connect_local()
sig.connect(print_handler, weak=False) # strong reference, safe for closures
Source: Signal.connect()
Module-level functions are safe because the module keeps them alive, which is why nearly all receivers are written at
module level. Decorated module-level receivers need no weak=False.
Where the code lives and where it is connected
Strictly speaking registration can happen anywhere, but the official recommendation is to keep receivers in a signals
submodule of the app they belong to, and to connect them in AppConfig.ready(). ready() runs once the app registry
is completely populated (see Project Structure & Apps), so models can
be imported safely there. The documentation advises against defining or importing receivers in the application’s root
module or models.py, to limit the side effects of importing code: a signal connected at import time of models.py
is connected whenever anything imports the models, including management commands and scripts that never needed it.
from django.apps import AppConfig
from django.db.models.signals import post_migrate
def create_default_groups(sender, using, apps, **kwargs):
# Use the historical models passed in, not a direct import of auth.models.
Group = apps.get_model("auth", "Group")
Group.objects.using(using).get_or_create(name="Librarians")
class CatalogConfig(AppConfig):
name = "catalog"
def ready(self):
# Importing the module connects every @receiver-decorated function.
from . import signals # noqa: F401
# Connect a plain function explicitly. sender=self filters on this app.
post_migrate.connect(
create_default_groups, sender=self, dispatch_uid="catalog.default_groups"
)
Source: Connecting receiver functions
Two details of the example come from the reference page. The post_migrate signal is sent once per app, with an
AppConfig as sender, and its apps argument is the migration state after the run, which is the right source of
models (like in a data migration) because it matches the schema as the migrations left it, exactly as in a data migration.
Handlers of post_migrate must not alter the database schema, because flush also sends it. The reference page also
notes that a receiver that uses an AppConfig instance as sender must be connected in ready(), since AppConfig
objects are recreated for tests that run with a modified INSTALLED_APPS. Check that the app is installed by its
config class ("catalog.apps.CatalogConfig", or just "catalog" when apps.py has a single config) so that ready()
actually runs, and run manage.py check and manage.py migrate to see the receiver at work: the scratch project’s
tests verify that the Librarians group exists after the test database is migrated.
Source: post_migrate
Two caveats apply to ready() itself. First, the documentation notes that it can run more than once during testing, so a
receiver that is a bound method of an instance that may be recreated should be guarded, which is the job of
dispatch_uid (next section). Second, the class_prepared signal is sent while the registry is being populated,
before ready() runs, so receivers for it cannot be connected there; the reference suggests connecting it from
AppConfig.__init__() without importing models or touching the registry. class_prepared is used mostly by Django
internally.
Connecting to one sender
Many signals fire constantly (pre_save fires for every save of every model). Passing sender= makes the dispatcher
call the receiver only when the sender matches. For model signals the sender is the model class, and the reference page
allows a lazy reference with the "app_label.ModelName" string, which avoids circular imports and works with swappable
models such as the user model. The scratch project’s custom post_save receiver for settings.AUTH_USER_MODEL shows
the pattern, and a test confirms that saving a Book does not trigger a receiver connected for "catalog.Author".
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Profile
@receiver(post_save, sender=settings.AUTH_USER_MODEL, dispatch_uid="catalog.create_profile")
def create_profile(sender, instance, created, raw, **kwargs):
if raw: # loaddata: the database may be inconsistent, do nothing
return
if created:
Profile.objects.create(user=instance, display_name=instance.get_username())
settings.AUTH_USER_MODEL is a lazy string, so this works even for a custom user model; the sibling page
Custom User Model & Backends explains why that is the right
reference. When the sender is passed as a lazy reference, disconnect() always returns None instead of a boolean, as
the documentation notes.
Preventing duplicate registrations with dispatch_uid
Without dispatch_uid Django identifies a receiver by the identity of its Python object and registers it only once.
For a module-level function, a static method or a class method that identity is stable, so connecting the same function
twice is a no-op. A bound method is different: the identity belongs to the particular instance, so connecting the same
method on a new instance registers an extra receiver. Supplying a unique dispatch_uid (usually a string, but any
hashable object works) makes the receiver bind once per distinct value regardless of how many instances or imports
there are.
class Backend:
def on_event(self, sender, **kwargs):
print("bound")
def connect_signals():
backend = Backend()
# Without dispatch_uid, every call registers one more receiver.
my_signal.connect(backend.on_event, weak=False, dispatch_uid="backend.on_event")
connect_signals()
connect_signals() # still a single receiver
my_signal.disconnect(dispatch_uid="backend.on_event") # True the first time, False after
Source: Preventing duplicate signals
The scratch tests run exactly this: connecting two separate Backend instances without a dispatch_uid produces two
calls per send(), with one it produces a single call, and disconnect(dispatch_uid=…) returns True and then
False. The other classic source of duplicates, a module that ends up imported under two dotted names so its function
objects differ, is also solved by dispatch_uid. Use a namespaced string ("catalog.create_profile") so two apps never
collide by accident, and use it on every receiver that has side effects that must not repeat, such as sending an email.
Model signals
The django.db.models.signals module defines the signals sent by the ORM. The table lists them with their senders and
the arguments that matter most.
| Signal | When and sender | Notable arguments |
|---|---|---|
|
Start and end of a model’s |
|
|
Start and end of |
|
|
Start and end of |
|
|
A |
|
|
A model class was registered; sender is the model class. Mostly internal. |
None beyond |
|
The |
|
Source: Model signals
A pre_save receiver is the right place for an automatically derived field, because it runs before the row is written
and the change is part of the same INSERT or UPDATE:
from django.db.models.signals import pre_save
from django.dispatch import receiver
from .models import Book
@receiver(pre_save, sender=Book)
def fill_search_text(sender, instance, **kwargs):
instance.search_text = instance.title.casefold()
Source: pre_save
The raw flag and fixtures
Both pre_save and post_save receive raw=True when the model is saved exactly as presented, which happens when a
fixture is loaded with loaddata or through the deserialisation API. At that moment the database may be inconsistent
(related rows may not exist yet), so the documentation tells receivers not to query or modify other records. This is why
the create_profile receiver above returns early when raw is true: a fixture that already contains the profile rows
would otherwise get a duplicate, or fail a constraint. The scratch test deserialises a user with raw set and asserts
that no profile is created. Since Django 6.1 loaddata also sends m2m_changed with raw=True when it loads
fixtures, and m2m_changed gained the raw argument at the same time, so many-to-many receivers should apply the same
guard.
Source: post_save
m2m_changed
The sender of m2m_changed is the intermediate class that Django creates for the relation, reachable as
Book.authors.through. The action argument identifies the phase (pre_add, post_add, pre_remove, post_remove,
pre_clear, post_clear), reverse tells you which side was modified, and model and pk_set describe the other
objects. For pre_clear and post_clear the pk_set is None. Because each add or remove sends two signals, receivers
nearly always test action first.
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Book
@receiver(m2m_changed, sender=Book.authors.through)
def authors_changed(sender, instance, action, reverse, model, pk_set, **kwargs):
if action == "post_add" and not reverse:
logger.info("book %s gained authors %s", instance.pk, sorted(pk_set))
Source: m2m_changed
In the scratch project book.authors.add(author) produced the expected log record
(book 1 gained authors [1]). The pk_set of an add may be a subset of what was submitted, because rows that already
exist are filtered out before the insert; see Relationships for the many-to-many
API.
When model signals do not fire
This is the most common source of "my signal does not run" reports, and each case follows from the way the signal is sent:
-
QuerySet.update()does not callsave(), so it sends neitherpre_savenorpost_save. -
bulk_create()andbulk_update()do not callsave()either, and the objects they create get nopost_save. -
Overriding
save(),delete()or__init__()without calling the parent implementation prevents the signals those methods send. The reference page warns about this explicitly. -
Since Django 6.1 the database-level
on_deleteoptions (DB_CASCADE,DB_SET_NULL,DB_SET_DEFAULT) delete or update rows inside the database with the SQLON DELETEclause and therefore do not triggerpre_deleteorpost_delete. -
QuerySet.delete()does sendpre_deleteandpost_delete, and since 4.1 theoriginargument holds the queryset.
The scratch test shows the first two: after bulk_create([Book(title="Draft")]) the search_text column is still empty
because pre_save never ran, and after Book.objects.filter(…).update(title="DUNE") the stored search_text is
unchanged. Code that depends on a signal therefore silently breaks as soon as someone writes a bulk operation, which is
one of the main arguments in Signals as a last resort.
Source: Model signals
Side effects after a save: transaction.on_commit()
A post_save receiver runs inside the transaction of the code that saved the object. If that transaction is later
rolled back (or an ATOMIC_REQUESTS request ends in an error), an email or a task enqueued from the receiver would
announce a row that never existed. The remedy is to register the side effect with transaction.on_commit() from inside
the receiver, so it runs only after a successful commit, and immediately when no transaction is active. See
Transactions, Raw SQL & Multiple Databases for the
transaction API.
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Book
@receiver(post_save, sender=Book, dispatch_uid="catalog.announce_book")
def announce_book(sender, instance, created, **kwargs):
if created:
# Defer side effects until the surrounding transaction has committed.
transaction.on_commit(lambda: logger.info("announce %s", instance.title))
class OnCommitTests(TestCase):
def test_on_commit_runs_after_commit(self):
with self.assertLogs("catalog.signals", "INFO") as cm:
with self.captureOnCommitCallbacks(execute=True) as callbacks:
Book.objects.create(title="Persuasion")
self.assertEqual(len(callbacks), 1)
self.assertIn("INFO:catalog.signals:announce Persuasion", cm.output)
Source: Performing actions after commit
TestCase wraps every test in a transaction that it rolls back, so on_commit callbacks never fire by themselves
there; captureOnCommitCallbacks(execute=True) collects and runs them, as in the test above. A receiver that enqueues a
Django task or a Celery job should do so from on_commit for the same reason.
Request, auth, test and other built-in signals
The remaining built-in signals are all dispatched with send() (the documentation notes that every built-in signal
except those in the async request-response cycle is sent with send()).
| Signal (module) | When | Arguments |
|---|---|---|
|
Django starts handling an HTTP request. |
|
|
Django has finished delivering the response. |
|
|
An exception occurred while processing a request. |
|
|
A setting changed through |
|
|
The test system rendered a template; not sent in normal operation. |
|
|
The database wrapper opened its first connection. |
|
|
Task lifecycle in the tasks framework. |
|
Source: Signals reference
The authentication framework adds user_logged_in, user_logged_out (both send request and user) and
user_login_failed (sends credentials and request), shown in Receiver functions and @receiver. They are documented with the login
helpers and are sent by login(), logout() and authenticate(), so they fire for the admin login, for any
LoginView and for your own views that call these functions.
Source: Authentication signals
The request signals carry very little information and run on every request, so the reference page itself recommends
considering middleware (see Middleware) before using them. A request-scoped receiver
cannot see the HttpRequest (only got_request_exception does). A quick check on both servers shows the difference
between handlers; the receiver below logs the handler class that request_finished supplies:
@receiver(request_finished, dispatch_uid="catalog.log_request_finished")
def log_request_finished(sender, **kwargs):
logger.warning("request finished via %s", getattr(sender, "__name__", sender))
# runserver (WSGI): request finished via WSGIHandler
# uvicorn (ASGI): request finished via ASGIHandler
Source: request_finished
Defining and sending custom signals
A custom signal is a Signal() instance created once at module level, conventionally in a module of its own (here
catalog/events.py) that both sender and receivers import. The signal does not declare its arguments: the sender is
required and every other keyword is by convention. The older providing_args constructor argument, which many tutorials
and books use to document them, was removed in Django 4.0, so write the arguments in a docstring or comment instead.
import django.dispatch
# Providing sender is mandatory; other keyword arguments are by convention.
book_borrowed = django.dispatch.Signal()
from .events import book_borrowed
def borrow_book(book, user):
# ... the real work: create a Loan row, check availability ...
results = book_borrowed.send_robust(sender=type(book), book=book, user=user)
return results
Source: Defining and sending signals
def on_borrow(sender, book, user, **kwargs):
return "recorded"
book_borrowed.connect(on_borrow, weak=False, dispatch_uid="reports.on_borrow")
The scratch test connects such a receiver, calls borrow_book(), and asserts the receiver saw the book and that the
result is [(on_borrow, "recorded")].
send() versus send_robust()
Both return a list of (receiver, response) tuples, one for each receiver that was called. They differ in error
handling. send() lets an exception propagate, so a failing receiver stops the others from being notified; a caller
that must be certain all receivers are told should use send_robust(). send_robust() catches every exception derived
from Exception, still calls the remaining receivers, and returns the exception instance as the response of the
receiver that failed, with its traceback on __traceback__.
sig = Signal()
@receiver(sig)
def first(sender, **kwargs):
raise ValueError("boom")
@receiver(sig)
def second(sender, **kwargs):
return "ok"
sig.send(sender=None) # raises ValueError; second() is never called
results = sig.send_robust(sender=None)
# [(first, ValueError('boom')), (second, 'ok')]
Source: Sending signals
The scratch test runs this sequence and checks the call order (["first"] for send(), ["first", "second"] for
send_robust()). Note that send_robust() swallows errors from the point of view of the sender, so check the returned
list (or log the exceptions) rather than assuming success; a built-in signal sent with send() (such as post_save)
propagates a receiver’s exception into the code that called save(), which is one more reason to keep receivers small
and defensive. Django also stores the full tracebacks in the returned exception objects, which makes logging them easy.
Signals sent by your own code do not need to be documented as part of a public API, but if a reusable app sends them the way Django does, document the signal and its arguments so third parties can connect to it. When sender and receiver are both inside your own project, the documentation recommends an explicit function call instead, because signals are implicit and harder to debug.
Async signals
Django 5.0 added Signal.asend() and Signal.asend_robust(), the coroutine counterparts of send() and
send_robust(). Receivers may be declared with async def and have the same signature as sync ones, and Django adapts
each receiver to the calling style: synchronous receivers called via asend() run through sync_to_async(), and
asynchronous receivers called via send() run through async_to_sync(). As with middleware, each adaptation costs a
little, so Django groups receivers by whether they are async before calling them, to limit the number of switches.
async def aborrow_book(book, user):
return await book_borrowed.asend_robust(sender=type(book), book=book, user=user)
import asyncio
from django.dispatch import Signal
sig = Signal()
async def slow(sender, **kwargs):
await asyncio.sleep(0.2)
return "async"
def quick(sender, **kwargs):
return "sync"
sig.connect(slow)
sig.connect(quick)
results = asyncio.run(sig.asend(sender=None))
# [(quick, 'sync'), (slow, 'async')]
Source: Sending signals
Grouping has two consequences that the scratch tests confirm. First, order is no longer registration order: an async
receiver registered before a sync one may run after it, and the returned list follows execution order (quick first
above), so do not rely on order between sync and async receivers. Second, async receivers within one call run
concurrently using asyncio.TaskGroup: two receivers that each sleep 0.2 seconds finished together in well under
0.35 seconds. In Django 6.1 this uses a task group (a failure cancels the siblings, as task groups do);
older versions (5.0 to 6.0) used asyncio.gather(), which has different cancellation behaviour. The 6.1 release note
spells out the change, so code that depends on how an error in one async receiver affects the others should be
re-tested when upgrading from 5.2 or 6.0.
Source: Sending signals
Everything the framework sends is dispatched with send() apart from the signals inside the async request-response
cycle: in Django’s ASGI handler request_started and request_finished are dispatched with asend(), so async
receivers of those two run on the event loop. The check with uvicorn in Request, auth, test and other built-in signals exercised the ASGI path, and the async test client
passed AsyncClientHandler as the sender of request_started. Async receivers are the right fit when a receiver awaits
I/O (a cache or an HTTP call from async code); keep CPU-bound work and blocking calls out of them. The async ORM, views
and middleware that surround these signals are covered on the async pages of this section.
Disconnecting receivers
Signal.disconnect(receiver=None, sender=None, dispatch_uid=None) takes the arguments of connect(), returns True if
a receiver was disconnected and False otherwise, and may be called with only a dispatch_uid to identify the receiver.
Use it for temporary hooks and for cleaning up in tests. Remember that disconnecting a receiver connected with a lazy
"app_label.Model" sender always returns None.
Source: Disconnecting signals
Testing code that uses signals
Test the observable effect, not the dispatcher: create the user and assert that the profile exists. The tests that
accompany this page do exactly that, plus a few focused ones. Whatever a test connects must be disconnected, or it leaks
into other tests; addCleanup keeps it tidy:
def test_custom_signal(self):
seen = []
def on_borrow(sender, book, user, **kwargs):
seen.append((sender.__name__, book.title, user))
book_borrowed.connect(on_borrow, weak=False)
self.addCleanup(book_borrowed.disconnect, on_borrow)
borrow_book(Book.objects.create(title="Emma"), "ada")
self.assertEqual(seen, [("Book", "Emma", "ada")])
Source: Testing tools
Two testing signals are useful in their own right. setting_changed is sent when a setting is overridden and when it is
restored (enter tells which), so a component that caches a setting value can reset the cache in a receiver; the
scratch test asserts the pair ("es", True) and ("en-us", False) for override_settings(LANGUAGE_CODE="es"). The
other is template_rendered, on which the test client builds response.context. The test runner, TestCase,
captureOnCommitCallbacks and the other helpers are covered on the testing page of this section.
from django.core.signals import setting_changed
from django.test import override_settings
def test_setting_changed(self):
events = []
def h(sender, setting, value, enter, **kw):
if setting == "LANGUAGE_CODE":
events.append((value, enter))
setting_changed.connect(h, weak=False)
self.addCleanup(setting_changed.disconnect, h)
with override_settings(LANGUAGE_CODE="es"):
pass
self.assertEqual(events, [("es", True), ("en-us", False)])
Source: setting_changed
Signals as a last resort
The official documentation carries two warnings that set the tone: signals give the appearance of loose coupling but can quickly lead to code that is hard to understand, adjust and debug, and, where possible, calling the handling code directly is the better option. The reference page adds specific advice for each group: for model signals, consider a helper method on a custom manager, or overriding model methods, before using a signal; for request and response signals, consider middleware first. Two Scoops of Django, one of the books consulted for this section, makes the same argument and calls signals a last resort; its reasoning matches the pitfalls shown above.
| Situation | Prefer | Why |
|---|---|---|
Create a related object when a model is created |
An explicit service function (below) or a model/manager method that does both steps. |
One visible call path; also works with |
Derive a field on every save |
Override |
No signal, and the database-level options also hold for bulk operations. |
Per-request work (ids, timing, headers) |
Middleware. |
It sees the request and response; request signals do not. |
Notify another app that does not import yours |
A custom signal, sent with |
This is what signals are for: a decoupled, optional listener. |
Work that must survive a crash or run elsewhere |
Tasks framework or Celery, enqueued from |
Signals live in the process and are lost with it. |
React to framework events in a reusable app |
Built-in signals connected in |
You cannot modify Django itself. |
The explicit alternative to the profile receiver is a small function that does both steps in one transaction and is called from the view or form that creates users:
from django.db import transaction
def create_user_with_profile(username):
"""The explicit alternative to a post_save receiver: one visible call."""
from django.contrib.auth import get_user_model
from .models import Profile
with transaction.atomic():
user = get_user_model().objects.create_user(username)
Profile.objects.update_or_create(user=user, defaults={"display_name": username})
return user
Source: Signals
Note that the receiver create_profile and this function overlap on purpose: with both in place a user created through
the function would get its profile from whichever runs first, and update_or_create makes the second a harmless update.
In real code choose one. A practical set of rules that follow from the pitfalls above:
-
Use a signal when the sender cannot know the receiver (framework events, reusable apps, optional integrations).
-
Use a direct call when both sides live in your project, even if it means passing one more argument.
-
Keep receivers short, idempotent and free of queries in
pre_init/post_init; accept**kwargs; always checkraw; defer side effects withon_commit. -
Document every custom signal and where it is connected; a grep for
@receiverand.connect(is the only way to discover who listens. -
Never depend on receiver order across apps, and never on a signal firing for bulk operations.
Where the books are outdated, and what changed recently
The books consulted for this section (listed on the section bibliography page) describe signals as they were in Django 1.x
and 2.x, and the core mechanism has not changed: @receiver, connect(), sender, dispatch_uid, m2m_changed and
registration in ready() all work today as they did then. What differs is at the edges.
-
Registration in
ready(). Books of the Django 2 era configure the app withdefault_app_configin the package__init__.pyso that a customAppConfigis used. That variable was removed in Django 4.1; since 3.2 Django discovers the singleAppConfiginapps.pyautomatically, andINSTALLED_APPSmay list just"catalog". -
providing_args. TheSignal(providing_args=[…])constructor argument that books and older tutorials show was removed in Django 4.0. CreateSignal()and document the arguments. -
Guidance has hardened. Newer docs warn explicitly that signals make code hard to maintain, and the reference suggests a manager helper, model method or middleware first. Do not treat the book recipes (profile creation on user save, for example) as the default design.
-
Async is new. None of the books has
asend(),asend_robust()or async receivers. -
Internals have moved on. Django 6.0 added signals for the tasks framework, and 6.1 changed how async receivers run, so receivers copied from older code should be reviewed.
| Version | Change |
|---|---|
4.0 |
|
4.1 |
|
5.0 |
|
6.0 |
Task signals ( |
6.1 |
|
Source: Django 6.1 release notes
Checklist
-
Receivers live in
signals.py, are imported inready(), and acceptsenderand**kwargs. -
The app is installed through its
AppConfigsoready()runs;manage.py checkpasses. -
Receivers with side effects set a
dispatch_uid; bound methods always do. -
Receivers of
pre_save,post_saveandm2m_changedhandleraw. -
Anything that must not happen on rollback goes through
transaction.on_commit(). -
Nothing relies on
bulk_create(),update()or a database-levelon_deletefiring a model signal. -
Tests disconnect what they connect, and async receivers are tested through
asend(). -
Before adding a signal, a direct call, manager method or middleware was considered and rejected.