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), apost_savesignal onUseris 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_updatedo not firepost_save— so logic in apost_savesignal 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
- Connect a
post_savereceiver onPatientand confirm it fires withcreated=TruethenFalse(reproduce the verified behaviour). - 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.
- Bulk-create appointments with
bulk_createand confirm thepost_savesignal does not fire — feel the inconsistency. - Write the legitimate case: a
post_saveonUserthat creates a profile row (a model you do not control), and explain why a direct call is not available here. - 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.
- For three side effects in Nidaan, decide signal versus direct call and justify each against the rule.
Official documentation
- Django — Signals — Connecting receivers and the built-in signals.
- Django — Signal reference —
post_saveand the rest, including their arguments. - Django — Where to put signal receivers (
ready()) — Making receivers discoverable.
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