The TLDR answer:

Most of the blogs you would get around Django signals explain how you can use signals on models save or request. But one of the most powerful features of Django is Signals and one can create custom signals.

Here are the steps to create a custom signal in Django -

  • Let's create an application app in Django.
  • Create a signals.py (name can be anything but I would prefer this naming convention since it helps to identify better) inside the app, you want the signal.
  • Declare the signal definition
# application/signals.py

from django import dispatch

some_task_done = dispatch.Signal(providing_args=["task_id"])
  • Let's say you want to raise a signal from tasks.py (it can be any file in any app). You can use the following syntax
# application/tasks.py

from application import signals

def do_some_task():
	# did some thing
    signals.some_task_done.send(sender='abc_task_done', task_id=123)
    
# Here sender can be anything, same are the arguments.
  • In the receivers.py you need to listen to the signal so that any signal raised would trigger the code written here.
# application/receivers.py

from django.dispatch import receiver
from application import signals


@receiver(signals.some_task_done)
def my_task_done(sender, task_id, **kwargs):
    print(sender, task_id)
    
# prints 'abc_task_done', 123
  • One more very important step which you need to do is, register the receiver.py in your app load. You
# application/apps.py

from django.apps import AppConfig


class ApplicationConfig(AppConfig):
    name = "application"

    def ready(self):
        from application import receivers
  • Once you add the receiver you also need to add the config to your app init file. In our case application/__init__.py
default_app_config = 'application.apps.ApplicationConfig'

That's it. You have a custom signal in Django. You can read more about it from the official documentation.

The Detailed Answer:

Django provides a lot of features out of the box and all of them are production-ready and battle-tested. But one of the most underrated out-of-the-box features which Django provides is Django signals. Though you might see a lot of developers use post_save signals or other models related signals, very few new developers would know that custom signals can also be created by developers. And this is one of the most powerful features which lets you decouple your application. But with great power, comes greater responsibilities, signals act like event-driven architecture, and just like any event-driven architecture, things can go easily out of hand if you don't have a good guideline and architecture in place. We shall discuss the issues with signals later in the article but first, let us first discuss what are signals and how to use them.

From the official documentation of signals

Django includes a “signal dispatcher” which helps allow decoupled applications get notified when actions occur elsewhere in the framework. In a nutshell, signals allow certain senders to notify a set of receivers that some action has taken place. They’re especially useful when many pieces of code may be interested in the same events.

One can easily go through the documentation and see what Django signals mean and how to use the built-in signals like post_save, pre_save, etc. But the real struggle comes when we want to create a custom signal. In fact, I myself sometimes struggle with the implementation still. Since there are many small details which we need to check. Here are the steps which I follow and recommend anyone who wants to work with signals in Django.

  • Always create a separate signals.py the file inside every app where you want to use a custom signal. In fact, I would say, try to avoid all the built-in signals for models. Why? Because when your codebase grows bigger, it becomes difficult to keep a check on who is listening to what and how things are getting updated, so always create a custom signal and then fire that signal from the model's def save(self) method.
  • Let's take an example app named, application. So we should create a signals.py file in the path application/signals.py with the following content
# application/signals.py

from django import dispatch

some_task_done = dispatch.Signal(providing_args=["task_id"])
  • Here we are creating a custom signal some_task_done which can be imported by any application and called.
  • Once we have a signal, let's create a use case, i.e. let us call it. For example, let us have a tasks.py file that calls the signal. Any other application can also import the signals and then call them.
# application/tasks.py

from application import signals

def do_some_task():
	# did some thing
    signals.some_task_done.send(sender='abc_task_done', task_id=123)
    
# Here sender can be anything, same are the arguments.
  • So we have seen how to create a signal and how to call it. But we have not seen what happens if we call a signal. This is the most tricky and important part. Whenever we fire a signal, we need some receiver to listen to the signal and perform some action. For this, we need to create a receivers.py file (filename can be anything, but try to keep this as a convention for better readability).
# application/receivers.py

from django.dispatch import receiver
from application import signals


@receiver(signals.some_task_done)
def my_task_done(sender, task_id, **kwargs):
    print(sender, task_id)
    
# prints 'abc_task_done', 123
  • Here the receiver decorator is subscribing to the some_task_done signal and whenever the signal would be dispatched then the receiver my_task_done function would be called.
  • Now comes the most important part. This is something many people miss which makes signals complicated. Make sure to import the receivers in your apps.py this is important since we need to tell Django to load the receivers when the app is ready so that it gets linked to the signals framework
# application/apps.py

from django.apps import AppConfig


class ApplicationConfig(AppConfig):
    name = "application"

    def ready(self):
        from application import receivers
  • Once you add the app config you also need to add it in your __init__.py file.
default_app_config = 'application.apps.ApplicationConfig'
INSTALLED_APPS = (
 ...,
 'application',
)

# Just replace it with

INSTALLED_APPS = (
 ...,
 'application.apps.ApplicationConfig',
)

And that's it. You have a working custom Django signal.

Let us see the advantages and use cases of Django signals -

  • The biggest advantage Signal brings is the introduction to event-driven architecture in Django. Though Django signals are synchronous, still it gives an additional programming paradigm
  • Decouple applications. If you ever faced a circular import issue, you know how difficult it becomes to solve it. One of the solutions I have learned is to use Django signals.
  • Multiple actions on a certain event. Whenever you have a lot of actions to be executed on a certain event, then use the Django signal. Like when you have new user signup you want to send a welcome email, want to activate a free trial, want to customize the profile, etc.

Now, let us figure out the disadvantages:

  • With Great Power, comes Greater Responsibilities. Debugging signals become very tricky, especially if you are new to Django.
  • I have worked in a Larger codebase where post_save signals were a nightmare and a lot of times we had to manually go to each file to see what went wrong.
  • When to use send vs send_robust is something one should always figure out. Most of the time we use to send, which might cause issues, also putting everything in a transaction might be a good option, but it might create additional DB load overhead.

Django signals are something that most of us set up once for an app and use it year over year, that's why it is very difficult to remember the small config which it requires to do the initial setup. Even today when I was writing this blog I struggled to make the signals working in the first go.