Django

Django Signals👨‍💻

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.

Key Takeaways

  • 1Signals implement the Observer pattern: senders dispatch events, receivers handle them, and neither needs to import the other
  • 2The most common built-in signals are `pre_save`, `post_save`, `pre_delete`, `post_delete`, and `m2m_changed` — they fire at specific points in a model's lifecycle
  • 3The `@receiver` decorator is the standard way to connect a handler to a signal — always register signals inside `AppConfig.ready()` to avoid import order issues
  • 4The `post_save` handler receives a `created` boolean that tells you whether the instance was just inserted or updated — this is critical for distinguishing first-time setup from subsequent saves
  • 5Signals run synchronously and in the same database transaction by default — a slow or failing handler will block or break the original operation
  • 6Signals are often overused: if both the sender and receiver live in the same app, prefer a direct method call, a model method override, or a service layer function instead

Master django signals

Take the Django ORM Mastery course with hands-on lessons and challenges.

Examples

post_save signal — react to model creation or update

python

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 signal — normalize data before it hits the database

python

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 signal — react to ManyToMany relationship changes

python

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 signal — define your own application events

python

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.

Proper signal registration in AppConfig.ready()

python

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.

Common Mistakes

Mistake:

Registering signals at module level in models.py, causing circular imports between models and signal handlers

Fix:

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.

Mistake:

Signals firing during migrations and data loading, causing unexpected side effects like sending emails or hitting external APIs

Fix:

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.

Mistake:

Not disconnecting signals in tests, leading to unpredictable test behavior and slow test suites from side effects like email sending or cache invalidation

Fix:

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.

Mistake:

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

Fix:

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.

Best Practices

  • Ask yourself: can the sender import the receiver? If yes, call it directly. Signals are for decoupling, not code organization
  • Always register signals in AppConfig.ready() and keep handlers in a dedicated signals.py file to avoid circular imports
  • Use update_fields in save() calls inside signal handlers to avoid infinite recursion (post_save triggering another post_save)
  • Accept **kwargs in every receiver function — Django may add new keyword arguments in future versions, and your handler must not break
  • Keep signal handlers thin: validate, then delegate to a service function or queue a background task. Never put complex business logic directly in a handler
  • For performance-sensitive paths, prefer django.dispatch.Signal.send_robust() which catches exceptions in individual receivers instead of letting one failure break the entire chain

Summary

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.

Practice Django with hands-on challenges

Learn django signals hands-on in your IDE

Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.

Related Concepts

Related Cheatsheets

Master Django with Stanza

Interactive lessons and challenges, right in your code editor.

Check the free courses. No credit card.