The core model signals, and registering receivers
coreintermediatepre_save/post_save fire around every Model.save() call; pre_delete/post_delete fire around every .delete(); m2m_changed fires when a ManyToManyField's relation set changes (add/remove/clear on a .add()/.remove()/.set()/.clear() call, or when the through table itself is modified). A receiver is a function accepting (sender, instance, **kwargs) — connected either via the @receiver decorator or Signal.connect(), and typically registered inside an app's AppConfig.ready() so the connection happens exactly once at startup, not accidentally repeated.
Think of it as
Signals exist as a decoupling mechanism — a way for code that has NOTHING to do with a model's own definition to react to it changing, without the model itself needing to know that code exists. post_save firing on EVERY save() (from a form, the admin, a management command, a data migration's RunPython) rather than only from specific code paths is exactly the point: a receiver doesn't care HOW the save happened, only THAT it happened. pre_save vs post_save exist as two separate hooks because "before the database write" and "after the database write succeeded" are genuinely different moments — pre_save can still modify the instance before it's persisted (though a model's own save() override is more common for that), while post_save is for reacting to a change that has definitely already committed at the row level (send a notification, invalidate a cache, trigger a background job). m2m_changed is its own separate signal, not just a variant of post_save, specifically because ManyToMany changes don't go through save() at all — .add()/.remove()/.set() on a related manager issue their own SQL directly against the through table, so there is no other hook that would see them.
What we're doing: React to a ManyToMany change on Article.tags, logging exactly which tags were added, registered correctly via AppConfig.ready().
- 1
- sender=Article.tags.through — the auto-generated through model, not Article itself, since m2m_changed fires on the relation table, not the model that declares the field.
- 10
- Importing inside ready() (not at the top of apps.py or signals.py imported elsewhere) is what guarantees this connection happens exactly once, after the app registry is ready.
Why this works: action lets one receiver handle every phase of an m2m change instead of six separate signal connections, and checking specifically for "post_add" (not "pre_add") means pk_set reflects objects that were actually, successfully added, not merely requested.
Connecting a signal receiver at module level in models.py instead of inside AppConfig.ready()
Wrong
Better
What you see: Depending on import order and how the module happens to get imported elsewhere (a test file, another app's models.py), the connection can run more than once — causing the receiver to fire multiple times per single save() — or fail to run at all if the module is never imported through a path that reaches it.
Why: Django's own documented convention is that signal handlers should be imported and connected inside AppConfig.ready(), specifically because that method is guaranteed to run exactly once, after every app's models are fully loaded — connecting at models.py's module level ties the connection to whatever, possibly inconsistent, import path happens to load that file first.
- Caller → Model: .save()
- Model → Caller: pre_save
- Model → Model: INSERT or UPDATE runs
- Model → Caller: post_save(created=True/False)
- Caller → Model: .delete()
- Model → Caller: pre_delete
- Model → Caller: post_delete
The core model signals, and when each fires
Together
Remember: post_save's created kwarg distinguishes insert from update. m2m_changed is a separate signal (not a post_save variant) with an action kwarg covering add/remove/clear phases, firing on the through model. Register receivers inside AppConfig.ready(), not at models.py module level, so the connection happens exactly once. A QuerySet-level bulk .delete() is not guaranteed to fire per-instance signals the same way an individual .delete() does.
See also: side effects and when to avoid signals · on commit and transaction timing · save and delete

