anishfyi.com Projects All writings

January 19, 20266 min readDjango, Patterns

Decoupling Side Effects with Django Signals

When Django signals keep code clean, when they hide bugs, and how to use them without regret.

Django signals let one part of an app react to something that happened in another part, without the two knowing about each other. They are genuinely useful, and they are also one of the easiest ways to make a codebase hard to follow. Both things are true at once.

What a signal is

A signal is the observer pattern. A sender announces that something occurred, and any number of receivers run in response. Django ships signals for the common model events: post_save, pre_delete, m2m_changed, and more.

A receiver

from django.db.models.signals import post_save
from django.dispatch import receiver


@receiver(post_save, sender=Order)
def notify_on_new_order(sender, instance, created, **kwargs):
    if created:
        send_order_confirmation.delay(instance.pk)

Where receivers should live

Put them in a signals.py module and connect it from the app config, so the receivers register exactly once when the app loads.

from django.apps import AppConfig


class OrdersConfig(AppConfig):
    name = "orders"

    def ready(self):
        from . import signals  # noqa: F401

When signals help

Signals shine for cross-cutting concerns that are not part of the core operation: invalidating a cache, writing an audit record, syncing a search index. The order does not care that a search index exists, and keeping that wiring out of the order code is the right call.

When signals hurt

If a side effect is essential to the operation, call it explicitly. Someone reading a view should see what it does without having to grep the project for receivers. Hidden control flow is the real cost of signals, and it is a steep one.

The save-inside-a-signal trap

A post_save receiver that calls save() on the same instance triggers itself, again and again. Update the row with queryset.update(), or pass update_fields and guard on it, so the receiver does not recurse.

Signals skip bulk operations

This catches people out. bulk_create, bulk_update, and queryset.update() do not fire per-row signals. If a cache or index relies on a signal to stay fresh, a bulk write will silently leave it stale. Know which path your code takes.

The rule I follow

Signals for cross-cutting concerns that the core code should not know about. Plain function calls for anything the operation actually depends on. Used that way, signals stay an asset instead of becoming a mystery.

Working on something in this territory? Book thirty minutes.