Introduction
In Django, signals provide a powerful way for applications to react to specific actions or events without tightly coupling code components together. They enable you to write clean, maintainable logic that responds automatically to certain changes — such as when a model instance is saved, deleted, or updated.
While Django comes with several built-in signals (like pre_save, post_save, post_delete, and request_finished), developers often need to create and connect custom signals to handle unique, domain-specific events.
This post dives deep into how custom signals work, how to create them, how to connect signal receivers dynamically, and how to handle them asynchronously for high performance.
By the end of this guide, you will understand:
- What Django signals are and how they function.
- How to create and register custom signals.
- How to connect multiple receivers to a single signal.
- How to send and listen to signals dynamically.
- How to handle signals asynchronously using Celery or threading.
What Are Django Signals?
Django signals are objects that send notifications when specific actions occur. A signal is emitted by a sender, and one or more receivers (functions) listen for and respond to that signal.
Think of a signal as a “message” being broadcasted — any part of your code can choose to “listen” and act on it.
For example, if a user signs up on your site, you might want to:
- Send a welcome email.
- Create a profile automatically.
- Log the signup event.
Instead of placing all of these actions inside the registration logic, you can emit a signal called user_registered, and have separate receivers handle these actions independently.
Basic Signal Workflow
Here’s a simplified explanation of how Django signals work:
- Define or import a signal (built-in or custom).
- Connect one or more receiver functions to that signal.
- Emit (send) the signal when a particular event occurs.
- Receivers execute automatically in response to that event.
This separation of logic makes your code modular and reusable.
Step 1: Importing Django’s Signal System
Before you create a custom signal, you must import Django’s signal handling module:
from django.dispatch import Signal, receiver
Signal: Used to create a new signal object.receiver: A decorator that helps register a function as a receiver for a given signal.
Step 2: Creating a Custom Signal
You can define a custom signal in any file (commonly in signals.py inside your app).
Example:
# myapp/signals.py
from django.dispatch import Signal
# Define a new signal
order_created = Signal()
This line creates a signal named order_created. It does not yet do anything — it’s just defined.
You can optionally define what arguments your signal will provide to receivers by using the providing_args parameter (though in newer Django versions, this is deprecated and unnecessary).
Step 3: Creating a Receiver Function
Next, create one or more receiver functions that will listen for this signal and react when it is triggered.
Example:
# myapp/receivers.py
from django.dispatch import receiver
from .signals import order_created
@receiver(order_created)
def send_order_notification(sender, **kwargs):
order = kwargs.get('order')
print(f"Notification: New order created for {order.customer_name}")
Here:
- The
@receiver(order_created)decorator connects thesend_order_notificationfunction to theorder_createdsignal. - Whenever the signal is emitted, this function will run.
- The
senderparameter identifies who sent the signal. - The
**kwargscontain extra data passed with the signal (like the order object).
Step 4: Sending the Signal
To trigger the signal, import and call its send() method wherever the event occurs.
For example, inside a view or model:
# myapp/views.py
from django.shortcuts import render
from .signals import order_created
from .models import Order
def create_order(request):
order = Order.objects.create(customer_name="Alice", total=250)
order_created.send(sender=Order, order=order)
return render(request, 'order_success.html', {'order': order})
When order_created.send() executes:
- The
send_order_notificationfunction will automatically be called. - The printed message will appear in the console or logs.
Step 5: Connecting Receivers Dynamically
In some situations, you may want to connect receivers without using decorators. This can be useful when you want to connect signals programmatically — for example, during app initialization.
You can do this using the .connect() method:
# myapp/apps.py
from django.apps import AppConfig
from django.dispatch import receiver
from .signals import order_created
from .receivers import send_order_notification
class MyAppConfig(AppConfig):
name = 'myapp'
def ready(self):
# Connect receiver dynamically
order_created.connect(send_order_notification)
This ensures the signal connection happens automatically when the app starts.
Step 6: Using Anonymous Receivers
You can also connect an inline (anonymous) function to a signal without using a decorator.
Example:
from .signals import order_created
def inline_receiver(sender, **kwargs):
order = kwargs.get('order')
print(f"Anonymous receiver: Order {order.id} processed.")
order_created.connect(inline_receiver)
This is especially helpful for testing or debugging when you need temporary behavior.
Step 7: Passing Extra Context Data
When sending a signal, you can include additional data via keyword arguments.
Example:
order_created.send(sender=Order, order=order, user=request.user, timestamp=datetime.now())
Your receiver function can then access these:
@receiver(order_created)
def log_order(sender, **kwargs):
user = kwargs.get('user')
timestamp = kwargs.get('timestamp')
print(f"Order created by {user.username} at {timestamp}")
This flexibility makes signals very powerful for sharing context data between components.
Step 8: Disconnecting a Signal
Sometimes you need to disconnect a receiver from a signal — for example, in testing or cleanup.
You can do this with the .disconnect() method:
order_created.disconnect(send_order_notification)
You can also specify sender if you want to disconnect only for a particular sender:
order_created.disconnect(send_order_notification, sender=Order)
This gives you fine-grained control over which receivers respond to signals.
Step 9: Handling Multiple Receivers
A single signal can have multiple receivers, and all of them will be called when the signal is sent.
Example:
@receiver(order_created)
def update_sales_report(sender, **kwargs):
print("Updating sales report...")
@receiver(order_created)
def send_thank_you_email(sender, **kwargs):
print("Sending thank-you email...")
When order_created.send() is called, both receivers run in sequence.
If one receiver raises an exception, Django will stop processing unless you handle it properly. You can use send_robust() instead of send() to continue even if one receiver fails.
Example:
order_created.send_robust(sender=Order, order=order)
Step 10: Using Signals Across Apps
You can use signals across different apps in your Django project. For example, an order app might send a signal, while a notification app listens for it.
To ensure signals are registered correctly, import them in your app’s apps.py file inside the ready() method:
# orders/apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
name = 'orders'
def ready(self):
import orders.signals
This guarantees that the signal connections are established when Django starts.
Step 11: Using Signals for Model Events
You can use signals to automatically react to model lifecycle events such as save, delete, or update.
Example:
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import CustomerProfile
@receiver(post_save, sender=CustomerProfile)
def profile_saved(sender, instance, created, **kwargs):
if created:
print(f"New profile created for {instance.user.username}")
else:
print(f"Profile updated for {instance.user.username}")
This uses Django’s built-in signal post_save, which is triggered after a model instance is saved.
Step 12: Handling Asynchronous Signals
By default, Django signals run synchronously — meaning they execute immediately and block further code execution until finished.
If your receiver performs time-consuming work (like sending emails or processing images), it can slow down your application.
To solve this, you can use asynchronous signal handling.
Using Threading
You can run signal receivers in a separate thread:
import threading
@receiver(order_created)
def async_notification(sender, **kwargs):
def task():
print("Processing order notification asynchronously...")
threading.Thread(target=task).start()
This way, your signal handler runs in the background without blocking the main request.
Using Celery for Background Tasks
If your project already uses Celery for background processing, signals and Celery work beautifully together.
Example:
# tasks.py
from celery import shared_task
@shared_task
def send_order_email_task(order_id):
print(f"Sending email for order {order_id}")
Now modify your signal receiver to trigger this task asynchronously:
# receivers.py
from .tasks import send_order_email_task
@receiver(order_created)
def handle_order(sender, **kwargs):
order = kwargs.get('order')
send_order_email_task.delay(order.id)
The .delay() method tells Celery to execute the task asynchronously, allowing your Django app to continue immediately.
Step 13: Testing Custom Signals
You can test Django signals by manually sending them and verifying the receiver’s behavior.
Example test case:
# tests.py
from django.test import TestCase
from .signals import order_created
from .models import Order
class SignalTest(TestCase):
def test_order_created_signal(self):
result = []
def test_receiver(sender, **kwargs):
result.append('signal_received')
order_created.connect(test_receiver)
order_created.send(sender=Order)
self.assertIn('signal_received', result)
This test verifies that the receiver was called when the signal was sent.
Step 14: Best Practices for Using Signals
To use signals effectively, follow these guidelines:
- Keep Signal Logic Simple – Don’t put complex or time-consuming operations directly inside signal receivers.
- Avoid Circular Imports – Import signals in
apps.pyrather than__init__.py. - Use Signals Sparingly – Use them for decoupled side-effects, not core business logic.
- Handle Errors Gracefully – Use
send_robust()when working with multiple receivers. - Use Logging – Add logs inside receivers for debugging and tracking signal execution.
Step 15: Real-World Use Cases
Here are some practical examples where custom signals are highly useful:
1. User Activity Tracking
When a user performs an action like logging in, updating a profile, or making a purchase, send a signal to log or analyze activity.
2. Notifications
Send email or push notifications when a specific event (like an order or comment) occurs.
3. Audit Trails
Track who made changes to models by sending signals on pre_save and post_save.
4. Cache Invalidation
Invalidate cache automatically when models are updated using post_delete or custom signals.
5. Analytics and Logging
Capture metrics and send them to analytics systems whenever data changes.
Step 16: Example – Complete Implementation
Let’s combine everything into a real example where a signal sends notifications when an order is created.
models.py
from django.db import models
class Order(models.Model):
customer_name = models.CharField(max_length=100)
total = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
signals.py
from django.dispatch import Signal
order_created = Signal()
receivers.py
from django.dispatch import receiver
from .signals import order_created
@receiver(order_created)
def notify_team(sender, **kwargs):
order = kwargs.get('order')
print(f"Team Notification: New order placed by {order.customer_name}")
@receiver(order_created)
def log_order(sender, **kwargs):
order = kwargs.get('order')
print(f"Log Entry: Order {order.id} created at {order.created_at}")
views.py
from django.shortcuts import render
from .models import Order
from .signals import order_created
def create_order(request):
order = Order.objects.create(customer_name="John Doe", total=500)
order_created.send(sender=Order, order=order)
return render(request, 'order_success.html', {'order': order})
When you call create_order, both receivers (notify_team and log_order) will execute automatically.
Step 17: Asynchronous Example with Celery
To improve performance, let’s integrate Celery for background notification.
tasks.py
from celery import shared_task
@shared_task
def async_log_order(order_id):
print(f"Asynchronously logging order {order_id}")
receivers.py
from django.dispatch import receiver
from .signals import order_created
from .tasks import async_log_order
@receiver(order_created)
def handle_async_log(sender, **kwargs):
order = kwargs.get('order')
async_log_order.delay(order.id)
Now, whenever an order is created, the logging happens asynchronously in the background.
Step 18: Debugging Signals
If your signals aren’t firing, check these common issues:
- Signal file not imported – Make sure signals are imported in
apps.pyunder theready()method. - Wrong sender specified – Ensure that the sender matches the class emitting the signal.
- Multiple imports – Avoid circular imports by keeping signal imports at the bottom of files.
- Database transactions – If signals run before transactions complete, use
transaction.on_commit()to delay execution.
Step 19: Performance Considerations
Signals introduce flexibility but can impact performance if not handled properly.
- Use asynchronous receivers for heavy tasks.
- Keep signal functions small and independent.
- Avoid chaining too many receivers.
- Use caching where appropriate to minimize repeated queries.
Leave a Reply