Django signals: use with care — best practices explained
Django signals are powerful—too powerful, sometimes. They let you hook into model lifecycle events like post_save and post_delete, triggering code automatically whenever those events occur. But that same power creates hidden control flow that's hard to trace, test, and debug. In this guide, we'll show you when signals genuinely solve a problem and when they're actually making your codebase harder to reason about.
What Django signals are
Signals are Django's implementation of the observer pattern. They're hooks that fire at specific moments in a model's lifecycle. The most common ones are:
pre_save— fires before a model instance is saved to the databasepost_save— fires after a model instance is savedpre_delete— fires before a model instance is deletedpost_delete— fires after a model instance is deletedm2m_changed— fires when a many-to-many relationship changes
You register a receiver function to listen for these signals. When the event occurs, Django calls your function automatically. Here's a minimal example:
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
@receiver(post_save, sender=Order)
def order_created(sender, instance, created, **kwargs):
if created:
# Do something when a new Order is saved
print(f"Order {instance.id} was created")
On the surface, this is clean. Your model doesn't need to know about the side effect. The signal handler lives elsewhere. No tight coupling, right? That's the appeal—and the trap.
Good uses for Django signals
Signals aren't inherently bad. There are genuine use cases where they shine. The key is recognizing them.
Cache invalidation
When you save or delete a model that's cached, you want to invalidate that cache. Signals are ideal here because cache invalidation is a true cross-cutting concern—it's not part of your business logic, and it shouldn't clutter your service layer.
from django.core.cache import cache
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Product
@receiver(post_save, sender=Product)
@receiver(post_delete, sender=Product)
def invalidate_product_cache(sender, instance, **kwargs):
cache.delete(f"product_{instance.id}")
cache.delete("products_list")
Why signals work here: you're not implementing business logic. You're ensuring consistency between your database and cache. Any code path that modifies a Product should trigger this, and signals ensure it does automatically.
Audit logging in third-party apps
If you're building a reusable Django app that third parties will plug into their projects, signals let you log model changes without asking users to modify their service layer. They register once in their app config and forget about it.
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.contrib.contenttypes.models import ContentType
from .models import AuditLog
@receiver(post_save)
def log_model_change(sender, instance, created, **kwargs):
# Only log models marked for auditing
if hasattr(sender, '_audit_log'):
AuditLog.objects.create(
content_type=ContentType.objects.get_for_model(sender),
object_id=instance.id,
action='created' if created else 'updated'
)
Why signals work here: third-party users don't need to instrument their code. The audit trail is automatic. Signals are the right abstraction for this kind of invisible infrastructure.
The hidden control flow problem
Now let's talk about why signals are often a mistake. The problem isn't technical—it's cognitive.
When you read this code:
order = Order.objects.create(customer=customer, total=100)
You see one line. But if there are three signal handlers registered for post_save on Order, you've actually triggered three additional operations. A developer reading your service layer won't know. They'll debug payment issues, notification timing, or inventory sync and find nothing in the obvious place. They'll eventually discover the signal handler buried in some other file, registered in apps.py, and wonder why that logic wasn't in the service layer to begin with.
This is especially problematic for business logic. Imagine this:
@receiver(post_save, sender=Order)
def send_order_confirmation(sender, instance, created, **kwargs):
if created and instance.status == 'confirmed':
send_email(instance.customer.email, "Your order is confirmed")
deduct_inventory(instance.items.all())
update_customer_tier(instance.customer)
This handler does real work. It's business logic. But it's hidden. It fires silently whenever an Order is saved. If you need to debug why inventory wasn't deducted, you're not going to look in the signals file first. And if you need to create an Order without triggering these effects (for a data migration, or a bulk operation), you're out of luck—signals always fire, unless you disable them globally with signal.disconnect(), which is a code smell.
Testing is also harder. To test that the order confirmation flow works, you need to instantiate the signal receiver, connect it, create an Order, and assert on the side effects. It's doable, but it adds friction. And if you forget to connect the signal in your test setup, you'll have false negatives.
Prefer explicit services
The better pattern is to make side effects explicit. Instead of relying on signals, call the function directly from your service layer. This is more lines of code—but it's clearer code.
Here's the refactor:
# models.py
from django.db import models
class Order(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
total = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
# services.py
from django.core.mail import send_mail
from .models import Order
def create_order(customer, items, total):
"""Create a new order and trigger all related workflows."""
order = Order.objects.create(
customer=customer,
total=total,
status='confirmed'
)
# Explicitly call side effects
send_order_confirmation_email(customer, order)
deduct_inventory_for_order(order)
update_customer_tier(customer)
return order
def send_order_confirmation_email(customer, order):
send_mail(
subject="Your order is confirmed",
message=f"Order {order.id} for ${order.total}",
from_email="orders@example.com",
recipient_list=[customer.email]
)
def deduct_inventory_for_order(order):
for item in order.items.all():
item.product.inventory -= item.quantity
item.product.save()
def update_customer_tier(customer):
total_spent = customer.orders.aggregate(
total=Sum('total'))['total'] or 0
if total_spent > 1000:
customer.tier = 'gold'
customer.save()
Now when someone reads the service function, they see the entire flow. No surprises. No hidden signals. Testing is straightforward—you call the function, check the return value, and assert on the side effects. And if you need to create an Order without sending an email (perhaps during a data import), you can do it directly with Order.objects.create() or refactor to have a lower-level helper.
The tradeoff: you've written more explicit code. But that's a feature, not a bug. Explicit is better than implicit—it's right there in the Zen of Python.
When signals still make sense
Don't misunderstand—this doesn't mean never use signals. It means use them for infrastructure concerns, not business logic.
Good signal candidates:
- Cache invalidation — As shown above.
- Audit trails — Logging who changed what and when, especially in third-party apps.
- Search index updates — Automatically reindex a model in Elasticsearch when it changes.
- Cleanup operations — Deleting associated files when a model is deleted.
- Metrics and monitoring — Incrementing a counter when a model is created.
The pattern: these operations are orthogonal to your business logic. They'd work the same way regardless of which code path created or updated the model. They're not specific to one feature or workflow.
Common mistakes
Putting business logic in signals: Your confirmation email logic, payment processing, or inventory deduction should be in services, not signal handlers. The signal handler gets hidden and hard to test.
Chaining signals: A signal that triggers another signal that triggers a third is a debugging nightmare. If signal A modifies model B, and model B has a signal that modifies model C, you've created invisible dependencies. Stick to one level of indirection.
Forgetting that signals fire on bulk operations: QuerySet.update() doesn't trigger signals. Neither does Model.objects.bulk_create() (unless you pass batch_size=1, which defeats the performance benefit). This inconsistency is error-prone. If you rely on signals for correctness, you'll get surprised by bulk operations that bypass them.
Not testing signal handlers: Because signals are decoupled from your service layer, they're easy to forget. Write tests that explicitly trigger the signal and assert on side effects. Don't assume they work just because the model saves.
Registering signals in models.py: Do it in apps.py in the `ready()` method. Signals registered in models.py can cause import issues and are harder to find.
# apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'orders'
def ready(self):
import orders.signals # Import signal handlers here
Key takeaways
- Django signals decouple code, but that decoupling hides control flow. Use them when that tradeoff is worth it.
- Good uses: cache invalidation, audit logging, search indexing, cleanup, metrics. These are infrastructure concerns, not business logic.
- Bad uses: order confirmation, payment processing, inventory management. These belong in services where the flow is explicit and testable.
- Prefer calling side effects directly from your service layer. It's more code, but it's clear code.
- If you do use signals, register them in
apps.py, not models.py. Test them explicitly. Be aware that bulk operations bypass signals. - The rule of thumb: if a future developer would reasonably expect that code to run when they call a function, put it in that function. If it's invisible infrastructure that should always run, a signal might be right.






Comments
Loading comments…