In Django, why does a post_save receiver not see the ManyToMany values a ModelForm just saved, and how does m2m_changed help?
answer
- instance first, relations second
- save_m2m() runs later
- sender is the through model
- action strings and pk_set
basics
~10 sModelForm.save() saves the instance, firing post_save, and only then writes the many-to-many rows, so the receiver sees the old relations. m2m_changed fires on add, remove, clear and set, with the through model as sender.
solid answer
~30 sA `ManyToManyField` lives in a separate join table, and rows can only be written once the instance has a primary key. So `ModelForm.save()` calls `instance.save()` first, which sends `post_save`, and then `_save_m2m()`; the admin likewise runs `save_model()` before `save_related()`. A `post_save` receiver reading `instance.tags.all()` sees the previous state. The fix is to listen to `m2m_changed` with `sender=Article.tags.through`, not `Article`, and react to `action` values such as `post_add`, `post_remove` and `post_clear`, using `pk_set` for the affected ids. `set()` sends remove and add pairs, or clear then add with `clear=True`.
code
python · 10 linesfrom django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Article
@receiver(m2m_changed, sender=Article.tags.through)
def tags_changed(sender, instance, action, reverse, pk_set, **kwargs):
if action in {"post_add", "post_remove", "post_clear"} and not reverse:
instance.refresh_tag_cache()go deeper
Recall that many-to-many links are saved after the object, so post_save cannot see them, and m2m_changed exists for that.
Explain the two-step save in ModelForm and the admin, the through model as sender, and the pre and post action pairs with pk_set.
Handle reverse-side changes, set() sending several action pairs, and why deletes do not send m2m_changed when you design denormalised data from it.
Decide whether relation-dependent side effects belong in signals at all, or in the form or service code that knows when both steps have finished.
## Why the ordering is forced A `ManyToManyField` is not a column on the model's table. Django stores it in an **intermediate ("through") table** with one row per link. A link row needs both primary keys, so the relations can only be written **after** the instance itself has been saved. Every save path therefore does two steps: 1. Save the instance — this is `Model.save()`, which sends `pre_save` and `post_save`. 2. Write the many-to-many links — `add()`, `remove()`, `clear()` or `set()` on the related manager. In a `ModelForm`, `save()` calls `self.instance.save()` and then `self._save_m2m()`. With `commit=False`, Django attaches `save_m2m()` to the form so you can call it after saving the instance yourself. The admin follows the same order: `save_model()` then `save_related()`. So a `post_save` receiver that reads `instance.tags.all()` runs **between** steps 1 and 2 and sees the relations **as they were before the edit** (empty, for a new object). ## The m2m_changed signal `django.db.models.signals.m2m_changed` is sent by the related manager whenever the relation changes. Its arguments: | Argument | Meaning | |---|---| | `sender` | The **through model**, for example `Article.tags.through` | | `instance` | The object whose relation changed (either side) | | `action` | `"pre_add"`, `"post_add"`, `"pre_remove"`, `"post_remove"`, `"pre_clear"`, `"post_clear"` | | `reverse` | Whether the change was made from the reverse side | | `model` | The class of the objects added, removed or cleared | | `pk_set` | Primary keys involved; `None` for the clear actions | | `using` | Database alias | | `raw` | Added in Django 6.1; `True` when `loaddata` sets relations from a fixture | ## Wiring it correctly ```python from django.db.models.signals import m2m_changed from django.dispatch import receiver from .models import Article @receiver(m2m_changed, sender=Article.tags.through) def tags_changed(sender, instance, action, reverse, pk_set, **kwargs): if action not in {"post_add", "post_remove", "post_clear"}: return if reverse: # instance is a Tag; pk_set holds Article ids return instance.refresh_tag_cache() ``` The classic mistakes: - **`sender=Article`** — the receiver never fires, because the sender is the through model. - **Acting on every action** — each operation sends a `pre_*` and a `post_*`, so an unfiltered receiver runs twice. - **Ignoring `reverse`** — `tag.article_set.add(article)` also fires the signal, with `instance` being the `Tag`. ## What `set()` sends `set()` is built on the other operations: - `article.tags.set([1, 2])` removes links that are no longer wanted and adds new ones, so you may see `pre_remove`/`post_remove` followed by `pre_add`/`post_add`. - `article.tags.set([1, 2], clear=True)` sends `pre_clear`/`post_clear` and then the add pair. For `pre_add`/`post_add`, `pk_set` may be a **subset** of what you passed, because ids already linked are filtered out to avoid an integrity error. For the remove actions, `pk_set` is what was *submitted*, even ids that were never linked. ## Doing the work after both steps instead When the relation-dependent work belongs to one form or one admin page, you can avoid the signal and run it at the point where both steps are known to be done: ```python from django.contrib import admin from .models import Article @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): def save_related(self, request, form, formsets, change): super().save_related(request, form, formsets, change) form.instance.refresh_tag_cache() # links are written by now ``` In a view, the equivalent is calling the follow-up after `form.save()` returns, or after `form.save_m2m()` when you used `commit=False`. This keeps the timing explicit, but only covers that one entry point; `m2m_changed` covers every caller of the related manager. ## Alternatives - If you control the code path, the clearest option is to do the follow-up work after `form.save()` returns (or after `save_related()` in the admin), when both steps are done. - An explicit `through` model does not change this: `add()` writes its link rows with `bulk_create()`, so the through model's own `post_save` does not fire for them, and `m2m_changed` remains the hook.
- Why is pk_set None for the clear actions?`clear()` deletes every link for the instance without first listing the related ids, so there is no set to report. If the receiver needs the removed ids, it can read them in `pre_clear`, before the rows are deleted, and stash them for the `post_clear` call.
- Does m2m_changed fire when the whole Article is deleted and its links disappear?No. Deleting the article removes its link rows through the deletion collector, and only the related manager's `add()`, `remove()`, `clear()` and `set()` send `m2m_changed`. For an auto-created through model the collector sends no delete signals for those rows either, so listen to `pre_delete` on `Article` if the removal matters.
saying these in an interview costs you the question
- Connects m2m_changed with the model class as sender instead of the through model
- Expects post_save to see the new ManyToMany values after ModelForm.save()
- Handles every action and so runs the logic twice per change
- Assumes pk_set always equals the ids passed to add()
- Forgets that changes made from the reverse side also fire the signal