Django Signals: A Complete Guide to Listening, Connecting, Sending, and Managing Application Events

This article provides a comprehensive explanation of Django’s signal system—an event‑driven mechanism that allows decoupled applications to react to actions occurring elsewhere in the framework. It covers how to define receivers, connect signals, use decorators, handle specific senders, organize signal code, and follow best practices to avoid complexity.

Django signals, receiver, senderrequest_finished, pre_savesignal dispatcher, Django events, decoupled apps

~5 min read • Updated Mar 15, 2026

What Are Django Signals?

Django includes a built‑in signal dispatcher that allows different parts of an application to communicate without tight coupling. Signals let certain senders notify a set of receivers when an action occurs. They are especially useful when multiple components need to react to the same event.

For example, a third‑party app can listen for settings changes:


from django.apps import AppConfig
from django.core.signals import setting_changed

def my_callback(sender, **kwargs):
    print("Setting changed!")

class MyAppConfig(AppConfig):
    def ready(self):
        setting_changed.connect(my_callback)

Warning: Signals may appear to promote loose coupling, but overuse can lead to code that is difficult to understand and debug. Whenever possible, call functions directly instead of relying on signals.


Listening to Signals

To receive a signal, you must register a receiver function using Signal.connect(). Each receiver is called in the order it was registered.

connect() Parameters

  • receiver — the callback function
  • sender — restricts signals to a specific sender
  • weak — prevents garbage collection when set to False
  • dispatch_uid — prevents duplicate registrations

Defining Receiver Functions


def my_callback(sender, **kwargs):
    print("Request finished!")

All receivers must accept sender and **kwargs. Even if a signal currently sends no arguments, Django may add them later.

Asynchronous Receivers


async def my_callback(sender, **kwargs):
    await asyncio.sleep(5)
    print("Request finished!")

Django automatically adapts receivers to synchronous or asynchronous signal dispatch.


Connecting Receivers

Method 1: Manual Connection


from django.core.signals import request_finished

request_finished.connect(my_callback)

Method 2: Using the @receiver Decorator


from django.core.signals import request_finished
from django.dispatch import receiver

@receiver(request_finished)
def my_callback(sender, **kwargs):
    print("Request finished!")

This approach is cleaner and more maintainable.


Where Should Signal Code Live?

Although signal code can technically live anywhere, best practice is:

  • Create a signals.py module inside your app
  • Connect signals inside the ready() method of your AppConfig

Example:


from django.apps import AppConfig

class MyAppConfig(AppConfig):
    def ready(self):
        from . import signals

Importing the signals module ensures that all @receiver decorators are executed.


Connecting to Signals from Specific Senders

Some signals fire frequently, but you may only care about events from a specific model. For example, pre_save fires before any model is saved, but you can restrict it:


from django.db.models.signals import pre_save
from django.dispatch import receiver
from myapp.models import MyModel

@receiver(pre_save, sender=MyModel)
def my_handler(sender, **kwargs):
    ...

Now the receiver runs only when MyModel is saved.


Best Practices for Using Signals

  • Use signals sparingly—they can make code harder to trace
  • Always accept **kwargs in receivers
  • Use dispatch_uid to avoid duplicate registrations
  • Be aware that ready() may run multiple times during testing

Conclusion

Django signals provide a powerful event‑driven mechanism for building decoupled applications. By defining receivers, using decorators, restricting senders, and organizing signal code properly, you can create flexible and maintainable event‑handling systems. However, signals should be used thoughtfully to avoid unnecessary complexity.

Preventing Duplicate Signal Registrations

By default, Django identifies receivers using their Python object identity. For module‑level functions, static methods, and class methods, this identity is stable—so connecting the same receiver multiple times has no effect:


def my_handler(sender, **kwargs):
    ...

my_signal.connect(my_handler)  # Running again does nothing.

Bound Methods Behave Differently

Bound methods depend on the instance they belong to. Each time you create a new instance, the method has a new identity:


def connect_signals():
    backend = Backend()
    my_signal.connect(backend.my_handler)

connect_signals()  # Registers a new receiver each time.

To prevent duplicate registrations, use dispatch_uid:


from django.core.signals import request_finished

request_finished.connect(my_callback, dispatch_uid="my_unique_identifier")

Defining Custom Signals

You can define your own signals using django.dispatch.Signal.


import django.dispatch

pizza_done = django.dispatch.Signal()

Important: If both sender and receiver are inside your own project, a direct function call is usually better than a signal.


Sending Signals

Django provides four methods for sending signals:

  • send() — synchronous, errors propagate
  • send_robust() — synchronous, catches exceptions
  • asend() — asynchronous, must be awaited
  • asend_robust() — asynchronous, catches exceptions

Example: Sending a Custom Signal


class PizzaStore:
    def send_pizza(self, toppings, size):
        pizza_done.send(
            sender=self.__class__,
            toppings=toppings,
            size=size
        )

All send methods return a list of tuples:


[(receiver, response), ...]

send() vs send_robust()

  • send() — stops if a receiver raises an exception
  • send_robust() — continues calling all receivers and returns the exception object

Exceptions returned by send_robust() include traceback information via __traceback__.


Asynchronous Signal Sending

asend() and asend_robust() are async coroutines:


async def asend_pizza(self, toppings, size):
    await pizza_done.asend(
        sender=self.__class__,
        toppings=toppings,
        size=size
    )

How Django Handles Sync and Async Receivers

  • Async receivers are executed concurrently using asyncio.gather()
  • Sync receivers are wrapped with sync_to_async() when called from asend()
  • Async receivers are wrapped with async_to_sync() when called from send()

To reduce overhead, Django groups receivers by sync/async type before calling them. This means execution order may differ slightly from registration order.


Disconnecting Signals

To remove a receiver:


Signal.disconnect(receiver=None, sender=None, dispatch_uid=None)

The method returns:

  • True — a receiver was disconnected
  • False — no matching receiver found
  • None — when sender is a lazy reference

If you used dispatch_uid, you can disconnect without passing the receiver function.


Conclusion

Django’s signal system is powerful and flexible, enabling decoupled communication between components. By using dispatch_uid, you can prevent duplicate registrations. With send(), send_robust(), asend(), and asend_robust(), you can send signals safely in both synchronous and asynchronous contexts. Finally, disconnect() gives you full control over removing receivers when needed.

Written & researched by Dr. Shahin Siami