A Django post_save receiver keeps a search index in sync, yet some edits never reach it; which ORM calls bypass model signals?
answer
- signals come from save() and delete()
- SQL-level writes
- bulk helpers
- database-side cascades in 6.1
basics
~10 sQuerySet.update(), bulk_create() and bulk_update() write SQL directly and send no pre_save or post_save; raw SQL sends nothing; DB_CASCADE deletes (Django 6.1) send no delete signals. QuerySet.delete() still sends pre_delete and post_delete per object.
solid answer
~30 s`pre_save` and `post_save` are sent from inside `Model.save()`, so anything that writes without calling `save()` bypasses them: `QuerySet.update()`, `bulk_create()`, `bulk_update()`, `cursor.execute()` and database triggers. Deletes are different: `QuerySet.delete()` and cascades collected by Django still send `pre_delete`/`post_delete` for every object, loading the objects into memory to do so. The exception, new in Django 6.1, is `on_delete=DB_CASCADE`, where the database removes the rows and no delete signals are sent. For an index kept by signals I either re-trigger it explicitly after bulk writes or move the sync to a periodic reconcile.
code
python · 8 linesfrom .models import Product
from .search import index_products
def zero_prices(ids):
Product.objects.filter(pk__in=ids).update(price=0)
# update() sends no post_save, so reindex explicitly.
index_products(Product.objects.filter(pk__in=ids))go deeper
Recall that signals come from save() and delete(), so update() and bulk_create() do not trigger save receivers.
Explain which paths skip which signal, why QuerySet.delete() still signals per object, and why that forces objects to be loaded.
Show how you would find the bypassing path behind a stale index, including admin actions, commands and DB_CASCADE, and repair it with explicit calls or reconciliation.
Decide whether derived data should depend on signals at all, given every bulk path is a hole, or on an explicit write service plus a periodic reconcile.
## Where model signals come from Django's model signals are sent by specific pieces of Python code, not by the database: - `pre_save` and `post_save` are sent by `Model.save()` (inside `save_base()`), before and after the write. - `pre_delete` and `post_delete` are sent by the **deletion collector**, which both `Model.delete()` and `QuerySet.delete()` use, and which also handles Python-level `on_delete` cascades. - `m2m_changed` is sent by the related managers of a `ManyToManyField` (`add()`, `remove()`, `clear()`, `set()`). So the rule is simple: **if the code path does not go through one of those methods, no signal is sent.** A receiver that maintains a derived structure (a search index, a denormalised counter, a cache entry) only sees what went through them. ## Calls that bypass save signals | Call | pre_save / post_save | Why | |---|---|---| | `obj.save()`, `Model.objects.create()`, `get_or_create()`, `update_or_create()` | Sent | They call `save()` | | `QuerySet.update(status="shipped")` | **Not sent** | One SQL `UPDATE`, no instances | | `bulk_create([...])` | **Not sent** | Batched `INSERT`, `save()` not called | | `bulk_update(objs, fields)` | **Not sent** | Batched `UPDATE`, `save()` not called | | `connection.cursor().execute(...)`, `raw()` writes | **Not sent** | Django never sees the change | | `loaddata` fixtures | Sent with `raw=True` | Saved "as presented" | The Django docs say it directly for `update()`: it works at the SQL level, does not call `save()`, and does not emit `pre_save` or `post_save`. ## Deletes behave differently A common wrong answer is "bulk deletes skip signals too." They mostly do not: 1. `QuerySet.delete()` emits `pre_delete` and `post_delete` **for every deleted object, including cascaded ones**. 2. To do that, Django must **fetch the objects into memory** first. When no receiver is listening and nothing cascades, the collector can do a "fast delete" with a single SQL `DELETE`; connecting a delete receiver removes that shortcut, which can make a large delete much slower. 3. **Django 6.1** added database-level `on_delete` options (`DB_CASCADE`, `DB_SET_NULL`, `DB_SET_DEFAULT`). With `DB_CASCADE` the database's own `ON DELETE CASCADE` removes the child rows; the release notes state that it does **not** trigger `pre_delete` or `post_delete`, because Django never loads those objects. ## Diagnosing the stale index For the scenario in the question, check in this order: - Search the code for `.update(`, `bulk_create`, `bulk_update` and raw cursors on the model; one of them is usually a management command or an admin action. - Check whether a relation to the model uses `DB_CASCADE`, which removes rows with no delete receiver called. - Confirm the receiver is connected at all: its module must be imported from the app's `ready()`, or it never registers in any process. ## What to do about it - **Make bulk paths explicit.** After an `update()` call, call the same indexing function on the affected ids. A small service function that does "write + reindex" is easier to reason about than a signal plus a list of exceptions. - **Reconcile periodically.** A scheduled job comparing `updated_at` against the index catches every path, including raw SQL. - **Do not replace `update()` with a loop of `save()` just to get signals** unless the row count is small: it turns one query into N queries plus N receiver calls. ## A snippet that looks right and is not ```python from django.db.models.signals import post_save from django.dispatch import receiver from .models import Product from .search import index_product @receiver(post_save, sender=Product) def reindex(sender, instance, **kwargs): index_product(instance) # Elsewhere, in an admin action: Product.objects.filter(pk__in=ids).update(price=0) # reindex() never runs ``` The receiver is correct; the admin action simply never calls `save()`. ## Quick reference for delete paths | Delete path | pre_delete / post_delete | |---|---| | `obj.delete()` | Sent for the object and each Python-level cascade | | `QuerySet.delete()` | Sent per object, cascades included; objects are loaded first | | `on_delete=CASCADE` child rows | Sent, because Django collects them | | `on_delete=DB_CASCADE` child rows (6.1) | **Not sent**; the database deletes them | | Raw `DELETE` SQL | Not sent | An index kept in sync by delete receivers therefore stays correct through ordinary deletes, and drifts only through database-side cascades and raw SQL.
- Why can adding a post_delete receiver make a large QuerySet.delete() much slower?Without delete receivers or cascades, Django's collector can issue one SQL `DELETE` without loading rows. Once `pre_delete` or `post_delete` has a listener for that model, Django must fetch every object into memory so it can send the signals per instance, turning a single statement into a fetch of every row plus per-object receiver calls.
- Your team switches a ForeignKey to on_delete=DB_CASCADE in Django 6.1. What happens to an audit receiver on the child model's post_delete?It stops seeing cascaded deletes. With `DB_CASCADE` the database removes child rows through its `ON DELETE CASCADE` clause, and Django neither loads nor signals them. Deleting a child directly still sends the signals; only the cascade path is silent, so the audit needs another source.
A post_save receiver is a doorbell wired to the front door, which is save(). update() and bulk_create() come in through the loading dock, so the bell never rings even though the goods are inside.
saying these in an interview costs you the question
- Believes QuerySet.update() calls save() on each matching row
- Thinks QuerySet.delete() skips pre_delete and post_delete
- Says bulk_create sends post_save once for the whole batch
- Expects DB_CASCADE deletions to trigger post_delete on the child model
- Fixes the gap by looping save() over millions of rows without considering cost