skip to content

A Django post_save receiver keeps a search index in sync, yet some edits never reach it; which ORM calls bypass model signals?

level: middleimportance: must knowfreq 64%

answer

  1. signals come from save() and delete()
  2. SQL-level writes
  3. bulk helpers
  4. database-side cascades in 6.1

basics

~10 s

QuerySet.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 lines
python
from .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

for a junior

Recall that signals come from save() and delete(), so update() and bulk_create() do not trigger save receivers.

for a middle

Explain which paths skip which signal, why QuerySet.delete() still signals per object, and why that forces objects to be loaded.

for a senior

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.

for a principal

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