When to Use Django Signals (and When to Avoid Them)

What Are Signals For

Django’s signal dispatcher is a clever piece of engineering. It lets you send a message from one part of your application and have another part listen for it without the two needing to know about each other directly.

The most common signals are tied to models like pre_save or post_delete. When a User object is saved the post_save signal is sent. Any function in your project registered as a receiver for that signal will then be executed.

It looks like this:

# models.py
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver

class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    bio = models.TextField(blank=True)

@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        UserProfile.objects.create(user=instance)

This seems great. A user is created and a profile is automatically made. It feels clean. The User model doesn’t need to know anything about UserProfile. But this magic comes at a cost.

The Cost of Magic

The problem with signals is that they hide the flow of execution. When you read the code for creating a User you have no idea that a UserProfile is also being created. It happens somewhere else invisibly. This is fine in a small project but as an application grows this invisibility becomes a huge liability.

Debugging becomes a hunt for hidden logic. You change something and a test breaks in a completely unrelated part of the app. Why? Because a signal connected them. New developers have a hard time building a mental model of how the system works because so much happens implicitly.

Code that is explicit is almost always better than code that is clever.

The One Good Reason to Use Signals

There is one scenario where signals are the right tool. That is when you need to connect two truly separate applications and you cannot or should not modify the source code of the sending application.

The best example is the one above. Let’s say you have a profiles app that provides UserProfile. You also use Django’s built in django.contrib.auth app. You cannot change the auth app to add a line that creates a profile when a user is saved. The auth app should not have a hard dependency on your profiles app.

Here a signal is the perfect solution. It lets your profiles app listen for an event from the auth app without the auth app knowing or caring that it exists. This is proper decoupling.

The key is that django.contrib.auth and your profiles app are separate self contained components.

When Not to Use a Signal

The anti pattern is using signals for logic within a single application. Imagine you have a blog app. When an Article is published you want to clear a cache and send a notification.

You might be tempted to do this:

# models.py from your blog app
@receiver(post_save, sender=Article)
def on_article_publish(sender, instance, **kwargs):
    if instance.is_published:
        clear_article_cache(instance)
        send_publication_notification(instance)

This works but it suffers from the same problem of invisibility. A much better way is to make the actions explicit.

You could override the model’s save method:

# models.py
class Article(models.Model):
    # ... fields
    
    def save(self, *args, **kwargs):
        # check for state change if needed
        super().save(*args, **kwargs)
        if self.is_published:
            clear_article_cache(self)
            send_publication_notification(self)

This is better because the logic is right there in the model. But it can clutter the save method. An even better pattern is to use a service function.

A service function encapsulates a business operation:

# services.py in your blog app
from django.utils import timezone

def publish_article(article):
    article.is_published = True
    article.published_at = timezone.now()
    article.save()
    
    clear_article_cache(article)
    send_publication_notification(article)

# views.py
from . import services

def article_publish_view(request, article_id):
    article = Article.objects.get(pk=article_id)
    services.publish_article(article)
    # ... return response

Now the flow of control is perfectly clear. When a request comes to the publish view we call the publish_article service. Inside that function we can read every step that happens in order. There is no magic. It is easy to understand test and debug.

A Simple Rule

Here is a simple rule for deciding whether to use a signal.

Ask yourself are the sender and receiver in the same logical part of my application? Could I just call a function instead?

If the answer is yes just call the function.

If the sender is in a third party app or a core Django app that you can’t modify then a signal is probably the right choice. It lets you react to events without creating a messy circular dependency or modifying code you don’t own.

Simplicity is about making the flow of logic obvious. Signals often do the opposite. They trade clarity for a little bit of convenience. This is rarely a good trade in the long run. The most maintainable code is the code that is easiest to read from top to bottom.

— Rishi Banerjee
September 2025