RizTech Academy logo
RizTech Academy
Architecture and Patterns in DjangoLesson 4 of 525 min

Signals, and when not to reach for them

Django signals let one part of your code react to an event elsewhere without the two being directly connected — when a Patient is saved, a receiver fires, and the code doing the saving never knows the receiver exists. This is the Observer pattern, built in, and it is genuinely useful in a few places. It is also overused, and misused signals are among the hardest Django bugs to track down. This lesson is how signals work, the narrow cases where they are right, and the much larger set of cases where a plain function call is better.

How signals work

A signal has senders and receivers. You connect a receiver function to a signal; when the signal fires, your function runs:

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

@receiver(post_save, sender=Patient)
def on_patient_saved(sender, instance, created, **kwargs):
    if created:
        print(f"New patient: {instance.name}")

Verified: saving a Patient fires this — created=True on the first save, created=False on an update. The built-in model signals you will meet: pre_save/post_save (before/after a save), pre_delete/ post_delete, and m2m_changed. The key property is decoupling: Patient.save() does not call on_patient_saved — the signal framework does, so the model and the reaction are not directly linked.

The genuine uses — narrow

Signals are the right tool in a specific situation: reacting to an event from code you do not control, or that genuinely should not know about the reaction. The honest list:

  • Reacting to a third-party or built-in model's events — you cannot edit Django's User.save(), so to do something when a user is created (create a matching profile row), a post_save signal on User is the correct tool. This is the strongest case: you cannot add the call to the sender.
  • Genuinely cross-cutting reactions — a concern (an audit log, cache invalidation) that must respond to many models' saves and truly does not belong to any of them.
  • Decoupling apps that must not import each other — a rare, deliberate architectural choice.

Notice the theme: signals are for when you cannot or should not make the call directly. That is a narrow set.

When NOT to use signals — the larger set

Here is the part that matters, because signals are reached for far too often. If you control the code that triggers the event, call the function directly instead of using a signal. The common misuse:

# MISUSE — a signal for your OWN model's own logic
@receiver(post_save, sender=Appointment)
def create_payment(sender, instance, created, **kwargs):
    if created:
        Payment.objects.create(appointment=instance, amount=instance.fee)

This looks clever but is a trap. The payment creation is your logic on your model, triggered by your save — so it should be an explicit call, in the model method or service that creates the appointment:

# BETTER — explicit, visible, ordered
def complete_appointment(appointment):
    with transaction.atomic():
        appointment.save()
        Payment.objects.create(appointment=appointment, amount=appointment.fee)   # right here, visible

Why the explicit version is better in almost every own-code case:

  • It is visible. Reading complete_appointment, you see the payment created. With the signal, saving an appointment anywhere silently creates a payment, and someone debugging "why did a payment appear?" has no call to follow — the effect happens "at a distance", which is the classic complaint about signals.
  • It is ordered and controllable. You control exactly when and whether the payment is created; a signal fires on every save, including ones where you did not want the side effect (a migration, a bulk update, a test fixture), causing surprising bugs.
  • It is testable and debuggable. An explicit call is in the stack trace; a signal handler's failure appears far from the save that triggered it.
  • bulk_create/bulk_update do not fire post_save — so logic in a post_save signal is silently skipped for bulk operations, a genuinely nasty inconsistency.

The rule: signals for events you do not control; direct calls for your own logic. Reaching for a signal to run your own code on your own model's save is the misuse to recognise and avoid.

The debugging cost, stated plainly

The deepest reason to be sparing is maintainability. Signals create action at a distance: an effect happens with no visible call causing it. Six months later, a developer (maybe you) sees payments being created and cannot find where — because the "where" is a @receiver in a file they are not looking at, triggered implicitly by a save. A codebase heavy with signals becomes a place where saving anything might do anything, and tracing cause and effect requires knowing every registered receiver. That cost is why experienced Django developers use signals sparingly and reach for explicit calls by default. When you do use a signal, keep the receiver small, put it where it is discoverable (an app's signals.py, connected in apps.py's ready()), and prefer it only for the "cannot call directly" cases above.

Check your work

How signals work. Connect a receiver to a signal (@receiver(post_save, sender=Model)); it fires on the event, decoupled from the sender. Verified: post_save fires created=True on create, False on update. Built-ins: pre_save/post_save, pre_delete/post_delete, m2m_changed.

The genuine uses. Reacting to a model you do not control (a User post_save to create a profile), truly cross-cutting reactions (audit log, cache invalidation), and deliberate app decoupling — the "cannot or should not call directly" cases.

The misuse. A signal for your own logic on your own model triggered by your own save — call the function directly instead.

Why explicit calls win. Visible (in the code and stack trace), ordered/controllable (not fired on every save), testable/debuggable — and bulk_create/bulk_update skip post_save entirely.

The core cost. Action at a distance — effects with no visible cause make signal-heavy code hard to trace; use signals sparingly and keep receivers small and discoverable.

Practice

  1. Connect a post_save receiver on Patient and confirm it fires with created=True then False (reproduce the verified behaviour).
  2. Implement "create a payment when an appointment is created" as a signal, then rewrite it as an explicit call in a method/service; argue why the explicit version is easier to follow.
  3. Bulk-create appointments with bulk_create and confirm the post_save signal does not fire — feel the inconsistency.
  4. Write the legitimate case: a post_save on User that creates a profile row (a model you do not control), and explain why a direct call is not available here.
  5. Trace a bug caused by "action at a distance": have a signal do something surprising on save, then try to find the cause without knowing about the receiver.
  6. For three side effects in Nidaan, decide signal versus direct call and justify each against the rule.

Official documentation

Next: structuring a project as it grows.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship