Django signals let decoupled parts of your application react to events without the event source knowing about the listeners. When a model is saved, deleted, or a ManyToMany relationship changes, Django dispatches a signal that any registered handler can pick up. This is the Observer pattern baked into the framework.
The appeal is obvious: you can add behavior (send an email, update a cache, write an audit log) without touching the original code. But signals come with a serious trade-off — they make control flow invisible. A model.save() call might trigger five different handlers scattered across your codebase, and none of that is apparent from reading the save call. This is why experienced Django developers treat signals as a tool of last resort, not a first instinct. If the code that fires the event and the code that reacts to it live in the same app, you almost certainly want a direct method call or a service layer instead. Signals shine when the listener genuinely cannot know about the sender — think third-party apps, pluggable integrations, or framework-level hooks.
Master django signals
Take the Django ORM Mastery course with hands-on lessons and challenges.
The 'created' boolean on post_save is the key decision point. When True, the instance was just INSERT-ed. When False, it was UPDATE-d. Always check this to avoid running creation logic on every save.
pre_save fires before the SQL INSERT or UPDATE, so you can modify instance fields and they will be saved. Note: this is a case where overriding the model's save() method is usually a better choice since the logic is tightly coupled to the model.
m2m_changed fires multiple times during a ManyToMany operation with different action values. Always filter on the 'post_' actions to react after the database change is committed. The sender must be the through table (Model.field.through), not the model itself.
Custom signals are the legitimate use case for Django signals: the order app should not import the notifications or inventory app. The signal provides a clean boundary between bounded contexts. Always pass keyword arguments (not positional) and accept **kwargs in receivers for forward compatibility.
Signal handlers must be imported before they can fire. AppConfig.ready() is called once during startup after all models are loaded, making it the safe place for signal registration. Never import signal handlers at module level in models.py — it causes circular imports.
Registering signals at module level in models.py, causing circular imports between models and signal handlers
Always register signals inside AppConfig.ready() by importing your signals module there. Keep signal handlers in a separate signals.py file. This guarantees all models are loaded before handlers try to reference them.
Signals firing during migrations and data loading, causing unexpected side effects like sending emails or hitting external APIs
Check for the 'raw' keyword argument in your handler: `if kwargs.get('raw'): return`. Raw is True during loaddata and fixture loading. For migrations, consider using Signal.disconnect() in your migration code or wrapping side effects in a try/except.
Not disconnecting signals in tests, leading to unpredictable test behavior and slow test suites from side effects like email sending or cache invalidation
Use a context manager or setUp/tearDown to disconnect signals during tests. For example: `post_save.disconnect(my_handler, sender=MyModel)` in setUp and reconnect in tearDown. Or use factory_boy's `@factory.django.mute_signals(post_save)` decorator.
Using signals for logic that belongs in the same app — e.g., a signal in the orders app that updates the order's own total. This hides straightforward logic behind an invisible dispatch
If the sender and receiver are in the same app, use a model method, override save(), or call a service function directly. Signals exist for cross-app decoupling, not for organizing code within a single app. Direct calls are easier to debug, test, and trace.
Django signals provide an Observer-pattern mechanism for reacting to model lifecycle events (pre_save, post_save, pre_delete, post_delete, m2m_changed) and custom application events. They excel at cross-app decoupling where the sender genuinely should not know about the receiver — third-party integrations, pluggable notifications, audit logging across bounded contexts. However, signals are frequently overused. If both sides of the equation live in the same app, a direct method call or service layer function is almost always clearer, easier to test, and easier to debug. When you do use signals, register them in AppConfig.ready(), keep handlers thin, and always accept **kwargs for forward compatibility.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.