← Back to list

Django Signals Are Silently Breaking Your App

Here’s What to Use Instead

Anas Issath in Level Up Coding · 2026-03-09 14:52 · 200 claps · 7.3 min read paywalled
#django #django-signals #django-best-practices #django-bug #backend-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Django Signals Are Silently Breaking Your App

Here’s What to Use Instead

It was a Thursday afternoon. I was two cups of coffee in, feeling good about life, when our support channel blew up.

“Users aren’t getting welcome emails.”

I checked the email service. Fine. Checked the SMTP config. Fine. Checked the logs. Nothing. Literally nothing. No errors, no warnings, no trace of anything going wrong.

Forty-five minutes later, I found it. A post_save signal on our User model was supposed to send a welcome email after registration. It worked perfectly in development. It worked in staging. But in production, with a slightly different app loading order and a bulk import script someone wrote last week — it just... didn't fire.

No error. No crash. Just silence.

That’s the day I stopped trusting Django signals for anything important.

The Problem Nobody Warns You About

Let me be clear. Django signals aren’t evil. The official Django docs describe them as a way for “decoupled applications to get notified when actions occur elsewhere in the framework.” That’s a solid concept. The problem is how most of us actually use them.

Here’s what a typical signal looks like in most Django projects:

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import User
from .services import send_welcome_email

@receiver(post_save, sender=User)
def user_post_save(sender, instance, created, **kwargs):
    if created:
        send_welcome_email(instance.email)

Looks clean, right? Decoupled. Elegant even. But here’s what’s hiding under the surface.

1. Signals Don’t Fire on Bulk Operations

This one catches almost everyone. If someone on your team writes this:

User.objects.bulk_create([
    User(email="alice@example.com"),
    User(email="bob@example.com"),
    User(email="charlie@example.com"),
])

None of those users get a welcome email. Zero. The post_save signal simply does not fire during bulk_create or bulk_update. Django's own documentation confirms this. It's not a bug. It's by design. But most developers don't discover this until something breaks in production.

Same thing with QuerySet.update():

User.objects.filter(is_active=False).update(is_active=True)

If you had a signal listening for is_active changes — too bad. It never fires.

2. Signals Run Synchronously (Yes, Really)

There’s a common misconception that signals run in the background. They don’t. When you call .save() on a model, Django goes through each registered receiver one by one, in order, and runs them. Synchronously. In the same request-response cycle.

So if your signal sends an email, calls an external API, and updates three related models — all of that happens before the response goes back to the user. Your 50ms save just became a 3-second save, and you have no idea why without digging through signal files across your project.

3. Signals Are Invisible

This is the real killer. When you read a model’s save() method and it just calls super().save(), you'd think saving is a simple database write. But somewhere in a signals.py file — maybe in the same app, maybe in a completely different one — there could be five receivers doing five different things.

I’ve worked on codebases where saving a single model triggered a chain of eleven different functions across seven files. Finding where a welcome email was being sent from took hours because there was no breadcrumb trail in the model itself.

As one experienced Django developer put it, the real victim isn’t the person who wrote the signal — it’s the developer who inherits the codebase and has to trace where unexpected behavior is coming from.

4. Signals Fail Silently

If a receiver raises an exception, it can break the entire save operation — or worse, it can fail without any visible error depending on how your error handling is set up. There’s no built-in retry mechanism. There’s no guarantee of delivery. If the process crashes between the save and the signal execution, your side effect just disappears.

So When Should You Actually Use Signals?

Before I show you the alternatives, let’s be fair. There are a few cases where signals are genuinely the right tool:

Hooking into third-party apps you don’t control. If you’re using a package like django-allauth or django-import-export and you need to react to events in their models, signals are your cleanest option. You can't modify their save() method, so a signal makes sense.

Applying the same logic across many unrelated models. If you need to auto-set a modified_at timestamp on 20 different models, a signal connected to a base class can be cleaner than overriding save() in every single one.

Queryset-level delete hooks. The pre_delete and post_delete signals fire even on queryset .delete() calls. If you need delete-time side effects that work on both single objects and querysets, signals handle this better than overriding the model's delete() method.

That’s about it. For everything else, there are better patterns.

Pattern 1: Override the Save Method (The Simple Fix)

The most straightforward replacement. Instead of hiding logic in a signal file, put it right where the action happens.

Before (with signals):

# models.py
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')

# signals.py (somewhere else in your project)
@receiver(post_save, sender=Order)
def order_post_save(sender, instance, created, **kwargs):
    if created:
        send_order_confirmation(instance)
        update_customer_stats(instance.customer)
        notify_warehouse(instance)

After (override save):

# models.py
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')

    def save(self, *args, **kwargs):
        is_new = self.pk is None
        super().save(*args, **kwargs)

        if is_new:
            self._on_created()

    def _on_created(self):
        send_order_confirmation(self)
        update_customer_stats(self.customer)
        notify_warehouse(self)

Now anyone reading the Order model can see exactly what happens when an order is created. No detective work required. The _on_created method keeps the save method clean while leaving a clear breadcrumb.

Pattern 2: The Service Layer (For Complex Business Logic)

When your side effects involve multiple models, external services, or conditional logic — a service layer is your best friend. Instead of scattering logic across signals and model methods, you centralize it in a service function that’s explicit about what it does.

# services/order_service.py

from django.db import transaction
from .models import Order, OrderLog
from .notifications import send_order_confirmation
from .tasks import notify_warehouse_async

def create_order(customer, items, total):
    """
    Creates an order and handles all side effects.
    This is the ONLY way orders should be created in this project.
    """
    with transaction.atomic():
        order = Order.objects.create(
            customer=customer,
            total=total,
            status='pending'
        )

        OrderLog.objects.create(
            order=order,
            action='created',
            details=f'Order created with {len(items)} items'
        )

        customer.total_orders += 1
        customer.save(update_fields=['total_orders'])

    # Outside the transaction — these can fail without
    # rolling back the order creation
    send_order_confirmation(order)
    notify_warehouse_async.delay(order.id)

    return order

Then in your view:

# views.py
from .services.order_service import create_order

class CreateOrderView(APIView):
    def post(self, request):
        serializer = OrderSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        order = create_order(
            customer=request.user.customer,
            items=serializer.validated_data['items'],
            total=serializer.validated_data['total']
        )

        return Response(OrderSerializer(order).data, status=201)

The beauty of this pattern is that everything is visible. You can read create_order top to bottom and know exactly what happens when an order is created. You can test it easily. You can add logging. You can handle errors granularly. And six months from now, when a new developer joins your team and asks "what happens when an order is created?" — the answer is in one file, not scattered across five.

Pattern 3: Domain Events with Celery (For Async Side Effects)

Sometimes you genuinely need the decoupling that signals promise but without the problems they bring. This is where explicit domain events paired with Celery give you the best of both worlds.

# events.py

import celery

@celery.shared_task
def handle_user_registered(user_id):
    """All side effects of user registration, run async."""
    from .models import User
    user = User.objects.get(id=user_id)

    send_welcome_email(user.email)
    create_default_workspace(user)
    track_signup_analytics(user)
    notify_sales_if_enterprise_domain(user.email)

# services/user_service.py
from django.db import transaction
from .events import handle_user_registered

def register_user(email, password, name):
    with transaction.atomic():
        user = User.objects.create_user(
            email=email,
            password=password,
            name=name
        )

    # Fire async after the transaction commits
    transaction.on_commit(
        lambda: handle_user_registered.delay(user.id)
    )

    return user

The key detail here is transaction.on_commit. This ensures the Celery task only fires after the database transaction has actually committed. So if something goes wrong during user creation and the transaction rolls back — the email never sends. No phantom welcome emails for users that don't exist.

This gives you:

  • True async execution (doesn’t slow down the request)
  • Guaranteed the user exists in the database before side effects run
  • Retry capability built into Celery if something fails
  • All logic in one explicit, readable place

A Quick Comparison

What I Actually Do Now

Here’s my rule of thumb after dealing with signal-related bugs for long enough:

Default to the service layer. Any time I’m creating or updating a model and there are side effects — emails, notifications, analytics, related model updates — it goes through a service function. No exceptions.

Override save for simple, model-specific logic. Things like auto-generating a slug, setting a default value, or validating a field before save. Small stuff that belongs to the model itself.

Use Celery tasks for anything slow. Email, external API calls, image processing, report generation. Anything that doesn’t need to finish before the response goes back to the user.

Use signals only for third-party hooks. When I literally cannot modify the source code of the model I need to react to.

The Takeaway

Django signals aren’t inherently bad, but they’re dramatically overused. The next time you reach for post_save, stop and ask yourself: "Could I just call this function explicitly?" If the answer is yes — and it almost always is — do that instead.

Your future self, your teammates, and that developer who’ll inherit your code at 3.07 AM during an incident — they’ll all thank you.

What’s the worst signal-related bug you’ve dealt with? I’d love to hear your war stories in the comments.

Thanks for reading! ❤

If this helped you, consider clapping (50 👏 s), following, or sharing it. A writer without readers is just talking to themselves — so your time means everything.

Let’s keep building better, together.

Anas Issath


메타데이터
post_id
6576e1d098ab
slug
django-signals-are-silently-breaking-your-app-6576e1d098ab
url
https://levelup.gitconnected.com/django-signals-are-silently-breaking-your-app-6576e1d098ab
canonical_url
https://levelup.gitconnected.com/django-signals-are-silently-breaking-your-app-6576e1d098ab
author_url
https://medium.com/@anas-issath
status
ok
fetched_at
2026-07-29 21:15:00