Django signals let one part of your app notify another that something happened, without the two parts importing each other. A sender dispatches a signal and any number of decoupled receivers run in response. They are Django's built-in implementation of the observer pattern, and they are most useful for cross-cutting side effects -- writing an audit log, invalidating a cache, sending a welcome email -- that you want to keep out of your core business logic.
This guide covers the built-in signals shipped with Django 5.x, how to connect receivers correctly, the single most common reason a signal "never fires", how to define your own custom signals, and -- just as importantly -- when you should not reach for a signal at all. The examples target Django 5.x on Python 3.11+. If you would rather hand this off, our team builds and maintains production Django systems through our Django development services.
Built-in Django signals
Django ships a set of ready-to-use signals. The model signals live in django.db.models.signals, request signals in django.core.signals, and migration signals in django.db.models.signals as well. Every receiver should accept sender and **kwargs so it keeps working if Django adds arguments in a future release.
| Signal | Sent when | Key arguments your receiver gets |
|---|---|---|
pre_save / post_save |
Before / after a model's save() |
sender, instance, raw, using, update_fields; post_save also gets created |
pre_delete / post_delete |
Before / after a model's delete() |
sender, instance, using, origin |
m2m_changed |
A ManyToManyField relation changes |
sender, instance, action, reverse, model, pk_set, using |
pre_init / post_init |
Before / after a model instance is instantiated | pre_init: sender, args, kwargs; post_init: sender, instance |
request_started / request_finished |
A request starts / finishes | sender, plus environ on request_started |
pre_migrate / post_migrate |
Before / after migrate runs for an app |
sender, app_config, verbosity, interactive, using, plan, apps |
A few notes that trip people up: post_save fires on both create and update -- use the created boolean to tell them apart. m2m_changed fires several times for one .set() call (a pre_remove/post_remove then pre_add/post_add), so always branch on action. And pre_save/post_save do not fire for bulk operations like QuerySet.update(), bulk_create(), or bulk_update() -- those hit the database directly and bypass save().
Connecting receivers
There are two equivalent ways to wire a receiver to a signal. The @receiver decorator is the most readable, and Signal.connect() is handy when you connect dynamically or to a third-party signal.
Always pass sender= to scope a model signal to one model, otherwise your receiver runs for every model's save or delete. And pass a stable dispatch_uid to guarantee the receiver is registered only once -- if the module is imported twice, Django would otherwise connect (and later fire) the same receiver twice.
# yourapp/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Author
# Option A: the decorator (most common)
@receiver(post_save, sender=Author, dispatch_uid="author_created_logger")
def log_author(sender, instance, created, **kwargs):
if created:
print(f"New author: {instance.full_name}")
# Option B: an explicit connect() call -- same effect
def log_author_connect(sender, instance, created, **kwargs):
...
post_save.connect(
log_author_connect,
sender=Author,
dispatch_uid="author_created_logger_connect",
)Where to register your signals (the #1 gotcha)
The most common Django signals question is "why isn't my signal firing?" The answer is almost always: the module that defines the receiver was never imported, so the @receiver decorator never ran. Connecting a receiver is a side effect of importing its module -- if Python never imports signals.py, the connection never happens.
The canonical fix is to import your signals module from your app config's ready() method. Put receivers in yourapp/signals.py, then import that module inside AppConfig.ready() in yourapp/apps.py. Doing the import in ready() (rather than at the top of models.py or in __init__.py) avoids "Apps aren't loaded yet" / "AppRegistryNotReady" errors, because by the time ready() runs the app registry is fully populated.
One historical note: older tutorials set default_app_config in __init__.py. That setting was deprecated in Django 3.2 and removed in Django 4.1 -- modern Django autodiscovers your AppConfig subclass, so you do not need it. Just define the config in apps.py and override ready().
# yourapp/apps.py
from django.apps import AppConfig
class YourAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "yourapp"
def ready(self):
# Importing the module runs its @receiver decorators and wires
# everything up. This is the canonical place to do it.
from . import signals # noqa: F401
# yourapp/__init__.py
# (Leave this empty. Do NOT set default_app_config -- removed in Django 4.1.
# Django autodiscovers YourAppConfig from apps.py.)A practical example: create a Profile when a User is created
The classic real-world use of post_save is creating a related row automatically -- for example a Profile whenever a new User is registered. The created flag is what keeps this from re-running on every later save.
# accounts/models.py
from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
# accounts/signals.py
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="create_user_profile")
def create_user_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)Writing a custom signal
When the built-in model and request signals do not cover your event, define your own. In modern Django a signal is just django.dispatch.Signal() with no arguments -- the old providing_args parameter was deprecated in Django 3.0 and removed in Django 4.0, so you document the expected keyword arguments in a comment or docstring instead.
Use signal.send(sender, **kwargs) to dispatch. If one misbehaving receiver should not stop the others (or crash the request), use send_robust() instead -- it catches any Exception a receiver raises and returns it in the results list rather than propagating it. In async code, asend() / asend_robust() are the awaitable equivalents.
# blog/signals.py
import django.dispatch
# A custom signal. Document the kwargs in a comment (providing_args is gone).
# kwargs: book (Book instance), author (Author instance)
book_published = django.dispatch.Signal()
# blog/receivers.py
from django.dispatch import receiver
from .signals import book_published
@receiver(book_published, dispatch_uid="email_author_on_publish")
def email_author_on_publish(sender, book, author, **kwargs):
# send_mail(...) to author.email -- your real email logic here
print(f"Emailing {author.full_name}: '{book.title}' is now live.")
# blog/services.py -- dispatch the signal from your business logic
from .signals import book_published
def publish_book(book):
book.status = "Published"
book.save(update_fields=["status"])
# send_robust() so a failing receiver can't break publishing
book_published.send_robust(sender=book.__class__, book=book, author=book.author)Signals vs the alternatives (use them sparingly)
Signals are powerful, but the Django documentation itself cautions against overusing them: they introduce "action at a distance" that makes control flow hard to follow, complicates testing, and can quietly add database queries inside a save. Before reaching for a signal, ask whether a more direct mechanism would be clearer.
- Override
save()/delete()on the model when the side effect belongs to that model and you control its code. It runs in the same place every reader looks, and it is trivial to test. - Call an explicit service function (e.g.
publish_book(book)) when the side effect is an application workflow. The flow is readable top to bottom and easy to mock. - Use a signal when you genuinely need decoupling -- reacting to a model in a reusable third-party app you cannot edit (such as
settings.AUTH_USER_MODEL), or letting several independent apps react to one event without depending on each other.
| Approach | Best when | Trade-off |
|---|---|---|
Override save()/delete() |
The side effect is intrinsic to a model you own | Couples the side effect to the model; skipped by bulk ops |
| Explicit service function | It is a deliberate workflow step | You must remember to call it everywhere; no auto-magic |
Signal (@receiver) |
You need true decoupling or to react to code you do not own | Hidden control flow; harder to test, debug, and reason about |
A good rule of thumb: if the receiver lives in the same app as the sender and you control both, an explicit call or a save() override is usually the better, more maintainable choice.
Testing signals and transaction safety
Two things bite teams in production. First, side effects firing before the database transaction commits. A post_save receiver that enqueues a Celery task or calls an external API can run inside the open transaction -- if that transaction later rolls back, you have emailed a user about a record that no longer exists, or queued a task whose row is not yet visible to the worker. Wrap such side effects in transaction.on_commit() so they only run after a successful commit.
Second, signals make tests noisy and slow because they fire on every fixture you create. In tests, disconnect the receiver around the code under test, or use a helper like factory.django.mute_signals (from factory_boy) to suppress them while building fixtures. Test the receiver function directly as a plain function, and separately assert that it is connected.
# accounts/signals.py -- defer side effects until the transaction commits
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
@receiver(post_save, sender=Order, dispatch_uid="notify_on_order")
def notify_on_order(sender, instance, created, **kwargs):
if created:
# Runs ONLY after the surrounding transaction commits successfully.
transaction.on_commit(lambda: send_order_email(instance.pk))
# tests -- mute signals while building fixtures (factory_boy)
import factory
@factory.django.mute_signals(post_save)
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
# or disconnect/reconnect manually inside a single test
def test_create_order_does_not_email():
post_save.disconnect(notify_on_order, sender=Order, dispatch_uid="notify_on_order")
try:
Order.objects.create(...) # no email sent
finally:
post_save.connect(notify_on_order, sender=Order, dispatch_uid="notify_on_order")At MicroPyramid we have spent 12+ years and 50+ delivered projects building and modernising Django applications, and "a signal that quietly does too much" is one of the most common sources of hard-to-trace bugs we untangle. Our preference in client code is explicit, testable control flow, with signals reserved for genuine decoupling -- and always made transaction-safe. If you need help designing event-driven Django the right way, see our Django development services and broader Python development services.
Frequently Asked Questions
Where should I register Django signals?
Put your receivers in a signals.py module inside the app, then import that module from your AppConfig.ready() method in apps.py. Importing the module is what runs the @receiver decorators and connects the receivers. Doing the import in ready() avoids "AppRegistryNotReady" errors that happen if you import models too early in __init__.py or at the top of models.py.
Why isn't my signal firing?
The overwhelmingly common cause is that the module defining the receiver is never imported, so the connection never happens. Make sure apps.py has from . import signals inside ready(), and that your app's config is the one Django loads. Other causes: you forgot sender= (or set the wrong sender), or the operation bypasses save() -- QuerySet.update(), bulk_create(), and bulk_update() do not send pre_save/post_save.
Should I use a signal or just override save()?
If the side effect is intrinsic to a model you own, overriding save() (or calling an explicit service function) is usually clearer and easier to test. Reach for a signal only when you need real decoupling -- reacting to a third-party model you cannot edit, such as the user model, or letting several independent apps respond to one event. The Django docs themselves warn against overusing signals because they hide control flow.
How do I stop a signal from running twice?
Pass a stable dispatch_uid string to @receiver or Signal.connect(). Django uses it to ensure the same receiver is registered only once even if its module gets imported more than once. Without it, a double import means the receiver connects twice and runs twice per event.
In post_save, how do I know if it was a create or an update?
Use the created keyword argument: it is True the first time an instance is saved and False on every subsequent save. The classic pattern -- creating a related Profile only when a User is first created -- guards its logic with if created: so it does not re-run on later updates.
How do I run side effects only after the transaction commits?
Wrap the side effect in transaction.on_commit(callback) inside your receiver. A post_save receiver can run while the transaction is still open, so emailing a user or queuing a Celery task directly risks acting on data that later rolls back -- or that a worker cannot see yet. on_commit defers the callback until the commit succeeds.