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 sender and **kwargs.

Receiver functions and @receiver

Register receivers

Put them in signals.py, import that module in AppConfig.ready(); never in models.py.

Where the code lives and where it is connected

React to one model only

Pass sender=Model (or the lazy "app_label.Model" string) to @receiver or connect().

Connecting to one sender

Avoid double registration

Pass dispatch_uid; it is essential for bound methods and for modules importable under two names.

Preventing duplicate registrations with dispatch_uid

Side effects after a save

post_save plus transaction.on_commit(); check created and raw.

Model signals

Announce your own event

Signal() plus send_robust(); but call a function when sender and receiver live in one project.

Defining and sending custom signals

Run receivers in an async view

await signal.asend(); sync receivers run through sync_to_async(), async ones concurrently.

Async signals

Test a receiver

Trigger the signal’s cause and assert the effect; clean up what you connect.

Testing code that uses signals

Decide whether to use a signal at all

Prefer an explicit call, a model method, a manager, or middleware.

Signals as a last resort

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.

sequenceDiagram participant S as Sender code participant D as Signal dispatcher participant R1 as Receiver 1 participant R2 as Receiver 2 S->>D: signal.send(sender=Book, instance=book) D->>R1: receiver(signal, sender, instance) R1-->>D: return value D->>R2: receiver(signal, sender, instance) R2-->>D: return value D-->>S: [(R1, value), (R2, value)]

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

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

Both ways of connecting (catalog/signals.py)
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

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

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.

Connect in ready() (catalog/apps.py)
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"
        )

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

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.

Table 1. Model and management signals (django.db.models.signals)
Signal When and sender Notable arguments

pre_init, post_init

Start and end of a model’s __init__(); sender is the model class.

instance (post only). Do not query in these receivers: they run for every instance a queryset loads.

pre_save, post_save

Start and end of save(); sender is the model class.

instance, raw, using, update_fields; created for post_save.

pre_delete, post_delete

Start and end of Model.delete() and QuerySet.delete(); sender is the model class.

instance, using, origin (the instance or queryset that started the deletion; added in 4.1).

m2m_changed

A ManyToManyField changed; sender is the automatic through class.

instance, action, reverse, model, pk_set, using, and raw (new in 6.1).

class_prepared

A model class was registered; sender is the model class. Mostly internal.

None beyond sender.

pre_migrate, post_migrate

The migrate command (and flush for post_migrate); sender is an AppConfig.

app_config, verbosity, interactive, stdout, using, plan, apps.

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:

Derive a column in pre_save (catalog/signals.py)
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 call save(), so it sends neither pre_save nor post_save.

  • bulk_create() and bulk_update() do not call save() either, and the objects they create get no post_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_delete options (DB_CASCADE, DB_SET_NULL, DB_SET_DEFAULT) delete or update rows inside the database with the SQL ON DELETE clause and therefore do not trigger pre_delete or post_delete.

  • QuerySet.delete() does send pre_delete and post_delete, and since 4.1 the origin argument 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.

Defer a side effect until commit (catalog/signals.py)
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))
Testing it (catalog/tests.py)
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)

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

Table 2. Request, test, database and task signals
Signal (module) When Arguments

request_started (django.core.signals)

Django starts handling an HTTP request.

sender is the handler class (for example WSGIHandler); environ (or scope under ASGI).

request_finished (django.core.signals)

Django has finished delivering the response.

sender is the handler class. Receivers must not assume sender is not None: the test client sends it with None.

got_request_exception (django.core.signals)

An exception occurred while processing a request.

sender is unused (always None); request.

setting_changed (django.test.signals, importable from django.core.signals)

A setting changed through override_settings or TestCase.settings(); sent on apply and on restore.

setting, value, enter.

template_rendered (django.test.signals)

The test system rendered a template; not sent in normal operation.

template, context.

connection_created (django.db.backends.signals)

The database wrapper opened its first connection.

connection; useful to run per-connection SET statements.

task_enqueued, task_started, task_finished (django.tasks.signals, new in 6.0)

Task lifecycle in the tasks framework.

task_result; sender is the backend class.

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.

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

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.

Define the signal (catalog/events.py)
import django.dispatch

# Providing sender is mandatory; other keyword arguments are by convention.
book_borrowed = django.dispatch.Signal()
Send it (catalog/services.py)
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
A receiver for it
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.

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.

Table 3. Choosing an alternative
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 bulk_create() and update() if you call it.

Derive a field on every save

Override save(), or use GeneratedField / db_default where the database can do it.

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 send_robust() and documented.

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 transaction.on_commit().

Signals live in the process and are lost with it.

React to framework events in a reusable app

Built-in signals connected in ready().

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 check raw; defer side effects with on_commit.

  • Document every custom signal and where it is connected; a grep for @receiver and .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 with default_app_config in the package __init__.py so that a custom AppConfig is used. That variable was removed in Django 4.1; since 3.2 Django discovers the single AppConfig in apps.py automatically, and INSTALLED_APPS may list just "catalog".

  • providing_args. The Signal(providing_args=[…​]) constructor argument that books and older tutorials show was removed in Django 4.0. Create Signal() 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.

Table 4. Signal changes by version
Version Change

4.0

providing_args removed from Signal. pre_migrate and post_migrate gained the stdout argument, which should be used instead of print() for verbose output.

4.1

pre_delete and post_delete gained the origin argument. default_app_config removed.

5.0

Signal.asend() and Signal.asend_robust() added; receivers may be synchronous or asynchronous and are adapted automatically.

6.0

Task signals (task_enqueued, task_started, task_finished) added in django.tasks.signals.

6.1

m2m_changed gained raw, sent with True by loaddata. Async receivers are executed with asyncio.TaskGroup instead of asyncio.gather(). DB_CASCADE-style database-level on_delete options do not send the delete signals.

Checklist

  • Receivers live in signals.py, are imported in ready(), and accept sender and **kwargs.

  • The app is installed through its AppConfig so ready() runs; manage.py check passes.

  • Receivers with side effects set a dispatch_uid; bound methods always do.

  • Receivers of pre_save, post_save and m2m_changed handle raw.

  • Anything that must not happen on rollback goes through transaction.on_commit().

  • Nothing relies on bulk_create(), update() or a database-level on_delete firing 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.