Filter concepts by levelShowing all levels.

Django · Section 41

Signals

Level
advanced
Read
20 min
Concepts
2

pre_save/post_save fire around every Model.save(); pre_delete/post_delete around every .delete(); m2m_changed around a ManyToManyField relation change, via the through model — post_save's created kwarg distinguishes insert from update, and m2m_changed's action kwarg covers add/remove/clear phases. A receiver is a function accepting (sender, instance, **kwargs), connected via @receiver or Signal.connect() and registered inside AppConfig.ready() so the connection happens exactly once. Receiver order follows connection order, not a stable guarantee; dispatch_uid prevents accidental duplicate registration. Signals run synchronously and in-process as part of the triggering call, and fire regardless of whether the enclosing transaction eventually commits, unless wrapped in transaction.on_commit() — real external work belongs in a background task, not inline. The roadmap's own senior-level judgment: signals suit framework-level, loosely-coupled hooks (cache invalidation, audit logging) well, but a critical business workflow is usually clearer, more testable, and easier to trace as explicit, directly-called code than as an implicit signal receiver.

This section

What is true here

  1. post_save's created kwarg distinguishes insert from update; m2m_changed is a separate signal firing on the through model, with an action kwarg.
  2. Register receivers inside AppConfig.ready(), not at module level, so the connection happens exactly once.
  3. dispatch_uid prevents accidental duplicate registration; receiver order otherwise just follows connection order.
  4. Signals run synchronously and fire even before a transaction commits — use transaction.on_commit() and hand real work to a background task.
  5. Signals fit framework-level, loosely-coupled hooks; critical business workflows are usually clearer as explicit, directly-called code.

What you will be able to do

  • Connect and correctly scope receivers to the right model signal, including m2m_changed's action phases
  • Register signal handlers safely, avoiding duplicate registration and import-order fragility
  • Defer signal side effects appropriately relative to transaction commit and background work
  • Judge when a signal is the right tool versus when explicit code is clearer and safer

The core model signals

pre/post_save, pre/post_delete, m2m_changed, and registering receivers correctly.

The core model signals, and registering receivers

coreintermediate

pre_save/post_save fire around every Model.save() call; pre_delete/post_delete fire around every .delete(); m2m_changed fires when a ManyToManyField's relation set changes (add/remove/clear on a .add()/.remove()/.set()/.clear() call, or when the through table itself is modified). A receiver is a function accepting (sender, instance, **kwargs) — connected either via the @receiver decorator or Signal.connect(), and typically registered inside an app's AppConfig.ready() so the connection happens exactly once at startup, not accidentally repeated.

Think of it as

Signals exist as a decoupling mechanism — a way for code that has NOTHING to do with a model's own definition to react to it changing, without the model itself needing to know that code exists. post_save firing on EVERY save() (from a form, the admin, a management command, a data migration's RunPython) rather than only from specific code paths is exactly the point: a receiver doesn't care HOW the save happened, only THAT it happened. pre_save vs post_save exist as two separate hooks because "before the database write" and "after the database write succeeded" are genuinely different moments — pre_save can still modify the instance before it's persisted (though a model's own save() override is more common for that), while post_save is for reacting to a change that has definitely already committed at the row level (send a notification, invalidate a cache, trigger a background job). m2m_changed is its own separate signal, not just a variant of post_save, specifically because ManyToMany changes don't go through save() at all — .add()/.remove()/.set() on a related manager issue their own SQL directly against the through table, so there is no other hook that would see them.

python
@receiver(post_save, sender=MyModel)
def my_receiver(sender, instance, created, **kwargs):
    ...

What we're doing: React to a ManyToMany change on Article.tags, logging exactly which tags were added, registered correctly via AppConfig.ready().

articles/signals.py + apps.pypython
@receiver(m2m_changed, sender=Article.tags.through)
def log_tag_changes(sender, instance, action, pk_set, **kwargs):
    if action == "post_add":
        logger.info("Article %s tagged with %s", instance.id, pk_set)

# apps.py
class ArticlesConfig(AppConfig):
    name = "articles"

    def ready(self):
        from . import signals  # noqa: F401 — registers the receivers above
1
sender=Article.tags.through — the auto-generated through model, not Article itself, since m2m_changed fires on the relation table, not the model that declares the field.
10
Importing inside ready() (not at the top of apps.py or signals.py imported elsewhere) is what guarantees this connection happens exactly once, after the app registry is ready.

Why this works: action lets one receiver handle every phase of an m2m change instead of six separate signal connections, and checking specifically for "post_add" (not "pre_add") means pk_set reflects objects that were actually, successfully added, not merely requested.

Connecting a signal receiver at module level in models.py instead of inside AppConfig.ready()

Wrong

python
# models.py — imported early, during app loading
post_save.connect(send_welcome_email, sender=User)

class User(AbstractUser):
    ...

Better

python
# apps.py
class AccountsConfig(AppConfig):
    def ready(self):
        from . import signals  # signals.py has the @receiver-decorated function

What you see: Depending on import order and how the module happens to get imported elsewhere (a test file, another app's models.py), the connection can run more than once — causing the receiver to fire multiple times per single save() — or fail to run at all if the module is never imported through a path that reaches it.

Why: Django's own documented convention is that signal handlers should be imported and connected inside AppConfig.ready(), specifically because that method is guaranteed to run exactly once, after every app's models are fully loaded — connecting at models.py's module level ties the connection to whatever, possibly inconsistent, import path happens to load that file first.

What fires around a save() and a delete()
Caller
Model
  1. 1. .save()
  2. 2. pre_save
  3. 3. INSERT or UPDATE runs
  4. 4. post_save(created=True/False)
  5. 5. .delete()
  6. 6. pre_delete
  7. 7. post_delete
  1. Caller → Model: .save()
  2. Model → Caller: pre_save
  3. Model → Model: INSERT or UPDATE runs
  4. Model → Caller: post_save(created=True/False)
  5. Caller → Model: .delete()
  6. Model → Caller: pre_delete
  7. Model → Caller: post_delete

The core model signals, and when each fires

The core model signals, and when each fires
SignalFires
pre_save / post_savebefore / after every Model.save() — created distinguishes insert from update
pre_delete / post_deletebefore / after every Model.delete() (per-instance for a QuerySet.delete())
m2m_changedwhen a ManyToManyField relation changes — add/remove/clear via the related manager

Together

python
@receiver(post_save, sender=Order)
def notify_on_order_created(sender, instance, created, **kwargs):
    if created:
        send_order_confirmation.delay(instance.id)

Remember: post_save's created kwarg distinguishes insert from update. m2m_changed is a separate signal (not a post_save variant) with an action kwarg covering add/remove/clear phases, firing on the through model. Register receivers inside AppConfig.ready(), not at models.py module level, so the connection happens exactly once. A QuerySet-level bulk .delete() is not guaranteed to fire per-instance signals the same way an individual .delete() does.

See also: side effects and when to avoid signals · on commit and transaction timing · save and delete

Advertisement

Ordering, side effects, and when to avoid signals

Duplicate registration, transaction timing, and the roadmap's own judgment on business-critical workflows.

Ordering, duplicate registration, side effects, and when NOT to use a signal

coreadvanced

Multiple receivers on the same signal run in the order they were CONNECTED, not a documented, guaranteed-stable order across app load orders — code should not depend on receiver A running before receiver B unless that's explicitly arranged. dispatch_uid on connect()/@receiver prevents the same receiver from being registered twice (e.g. if a module happens to get imported more than once), which would otherwise cause it to fire multiple times per single event. Signals run SYNCHRONOUSLY, in-process, as part of the same call that triggered them — a slow or failing receiver directly slows down or breaks the save()/delete() that fired it, unless the receiver defers real work (e.g. queuing a background task) rather than doing it inline. The roadmap's own explicit judgment: signals are genuinely useful for framework-level, loosely-coupled hooks, but a CRITICAL business workflow (charge a payment, fulfill an order) is usually better as explicit, directly-called code — traceable by reading the caller, not requiring a search across the codebase for "what listens to this signal."

Think of it as

Signal ordering being merely "connection order" rather than something Django guarantees or lets you declare is a direct consequence of signals being a decoupling mechanism — if the SENDER had to know or control receiver ordering, that would reintroduce exactly the coupling signals exist to avoid. This is precisely why relying on ordering between two independent receivers is fragile: their relative connection order depends on app-loading order, which is influenced by INSTALLED_APPS position and import side effects, not something obviously stable across refactors. dispatch_uid exists because the same signals.py module, or the same connect() call, importing more than once (a real risk with certain import patterns, testing setups, or autoreload behavior) would otherwise silently double-register a receiver, causing every event to fire it twice — dispatch_uid makes connect() idempotent for a given uid, closing that gap explicitly rather than requiring careful import discipline everywhere. Signals running synchronously and in-process is the single biggest practical risk: a receiver that does real work (sends an email, calls an external API) inside post_save means every single .save() anywhere in the codebase now pays that cost and inherits that failure mode — which is exactly the reasoning behind the roadmap's senior-level judgment that a signal is the wrong tool for anything business-critical: the connection between "this save happened" and "this critical side effect ran" becomes invisible at the call site, discoverable only by knowing to search for signal receivers, which is a real cost against traceability that explicit code (a service function the caller invokes directly) doesn't have.

python
@receiver(post_save, sender=MyModel, dispatch_uid="app.unique_name")
def my_receiver(sender, instance, **kwargs):
    transaction.on_commit(lambda: do_real_work(instance.id))

What we're doing: A cache-invalidation signal receiver — a genuinely good fit for signals, kept safe from duplicate registration and pre-commit timing.

articles/signals.pypython
@receiver(post_save, sender=Article, dispatch_uid="articles.invalidate_cache_on_save")
@receiver(post_delete, sender=Article, dispatch_uid="articles.invalidate_cache_on_delete")
def invalidate_article_cache(sender, instance, **kwargs):
    transaction.on_commit(lambda: cache.delete(f"article:{instance.id}"))
1
dispatch_uid is unique per stacked decorator — reusing the same string across both would make the second connect() a no-op, silently dropping the delete-signal registration.
4
on_commit() defers the actual cache invalidation until the transaction truly commits — invalidating before a rollback would leave a stale cache entry undetected.

Why this works: Cache invalidation is exactly the kind of framework-level, loosely-coupled concern signals suit well — it genuinely doesn't matter which code path saved or deleted the Article, the cache just needs to know it changed, which is a decoupling signals provide cleanly.

Using a post_save signal to process a payment or trigger order fulfillment — a business-critical workflow

Wrong

python
@receiver(post_save, sender=Order)
def process_payment_on_save(sender, instance, created, **kwargs):
    if created:
        charge_customer(instance)   # runs on EVERY Order.save() anywhere — admin, migration, tests, ...

Better

python
def create_order(customer, items):
    order = Order.objects.create(customer=customer, items=items)
    charge_customer(order)   # explicit, directly-called, traceable from the caller
    return order

What you see: A payment gets charged from an unexpected code path — a data migration that re-saves Order rows, a test factory, an admin bulk action — none of which intended to trigger a real charge, because post_save has no way to distinguish "the checkout flow saved this" from "anything, anywhere saved this".

Why: Business-critical logic tied to save() via a signal is invisible at every call site that triggers it — a developer reading create_order()'s checkout code, or a migration that bulk-resaves Order rows, has no local indication a payment will fire. The roadmap's own judgment applies directly here: this exact shape (a side effect with real financial/business consequences, hidden behind a save()) is the case signals are a poor fit for — explicit, directly-called code makes the payment trigger visible exactly where it should be decided.

When a signal fits, and when explicit code fits better

Good fit for a signal

  • +Framework-level, loosely-coupled hooks
  • +Cache invalidation, search-index updates, audit logging
  • +Doesn't matter which code path triggered the save

Better as explicit code

  • Business-critical workflows — payment, order fulfillment
  • Fires on EVERY save() anywhere — admin, migrations, tests
  • Traceable from the caller, not hidden behind save()
  • Good fit for a signal
    • Framework-level, loosely-coupled hooks
    • Cache invalidation, search-index updates, audit logging
    • Doesn't matter which code path triggered the save
  • Better as explicit code
    • Business-critical workflows — payment, order fulfillment
    • Fires on EVERY save() anywhere — admin, migrations, tests
    • Traceable from the caller, not hidden behind save()

Signal risk, and the usual mitigation

Signal risk, and the usual mitigation
RiskMitigation
Duplicate registration (fires twice per event)dispatch_uid on connect()/@receiver
Slow/failing receiver blocks the triggering save()/delete()hand off real work to a background task instead of running it inline
Receiver fires before the transaction actually commitswrap the receiver's logic with transaction.on_commit()
Hidden, hard-to-trace business logicuse explicit, directly-called code for anything business-critical

Together

python
@receiver(post_save, sender=Order, dispatch_uid="orders.notify_on_create")
def notify_on_order_created(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(lambda: send_order_confirmation.delay(instance.id))

Remember: Receiver order follows connection order, not a stable guarantee — dispatch_uid prevents accidental duplicate registration. Signals run synchronously in-process; hand real work off to a background task rather than running it inline. A receiver fires regardless of whether the enclosing transaction commits, unless wrapped in transaction.on_commit(). Signals suit framework-level, loosely-coupled hooks — critical business workflows are usually clearer as explicit, directly-called code.

See also: the core model signals · on commit and transaction timing

Advertisement