In Django's ORM, which write calls bypass a model's save() method and its pre_save and post_save signals, and what does QuerySet.delete() still run?
answer
- SQL-level writes skip Python hooks
- update(), bulk_create(), bulk_update()
- delete() is collected, not raw
- per-object delete signals, no Model.delete()
basics
~10 sQuerySet.update(), bulk_create() and bulk_update() skip save() and the pre_save/post_save signals. QuerySet.delete() skips each instance's delete() method but still sends pre_delete and post_delete for every row it deletes, including Python-emulated cascades.
solid answer
~40 sAnything that goes through `Model.save()` runs an overridden `save()` and sends `pre_save`/`post_save`: `create()`, `get_or_create()` and `update_or_create()` all do. `QuerySet.update()`, `bulk_create()` and `bulk_update()` write at the SQL level, so neither the `save()` override nor those signals run; `update()` and `bulk_update()` also leave `auto_now` fields untouched, while `bulk_create()` still fills them. `QuerySet.delete()` is different: it never calls each instance's `delete()` method, but it hands the rows to Django's deletion **collector**, which sends `pre_delete` and `post_delete` for every deleted object, cascaded ones included, unless the relation uses a database-level `on_delete` option such as `DB_CASCADE` (Django 6.1). With no delete receivers and no cascades, the collector takes a fast path and issues a single `DELETE` without loading rows.
code
python · 21 linesfrom django.db import models
class Workshop(models.Model):
title = models.CharField(max_length=200)
archived = models.BooleanField(default=False)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.title = self.title.strip()
super().save(*args, **kwargs)
# save() override and pre_save/post_save run; updated_at is bumped.
Workshop.objects.create(title=" Intro ")
# Single UPDATE: no save() override, no signals, updated_at unchanged.
Workshop.objects.filter(archived=False).update(title="Renamed")
# No Model.delete() call; pre_delete/post_delete sent per row.
deleted, per_model = Workshop.objects.filter(archived=True).delete()go deeper
Recall that update(), bulk_create() and bulk_update() write straight to SQL and skip save() and its signals.
Explain the delete asymmetry: no Model.delete() call, yet the collector sends pre_delete and post_delete per object, cascades included, and returns counts per model.
Diagnose the production effects: stale auto_now columns after update(), skipped business rules in save() overrides, and receivers that disable the fast delete path on big tables.
Decide where invariants live so every write path honours them: database constraints and defaults, explicit service functions, or signals with their known gaps.
## Why some calls skip `save()` In Django, `Model.save()` is ordinary Python: it runs the field `pre_save()` hooks, sends the **`pre_save`** signal, writes one row, then sends **`post_save`**. Any business logic you add by overriding `save()` or connecting a receiver lives on that path. A QuerySet method that writes many rows per SQL statement never calls each instance's `save()`, so the hooks attached to `save()` do not run. The Django documentation says there is **no workaround** for bulk create and update: none of `save()`, `pre_save` or `post_save` is called. ## The write calls, side by side | Call | Runs overridden `save()` | `pre_save` / `post_save` | Fills `auto_now` | |---|---|---|---| | `create()` | yes | yes | yes | | `get_or_create()` (insert path) | yes | yes | yes | | `update_or_create()` | yes (save with `update_fields`) | yes | yes | | `instance.save()` | yes | yes | yes | | `QuerySet.update()` | **no** | **no** | **no** | | `bulk_create()` | **no** | **no** | yes (field `pre_save()` is applied while building the INSERT) | | `bulk_update()` | **no** | **no** | **no** | Two details in that table catch people out: - **`auto_now`** timestamps are set by the field's own `pre_save()` hook. `bulk_create()` calls that hook for each object while assembling the INSERT, so `auto_now_add` and `auto_now` work there. `update()` and `bulk_update()` only write the values you give them, so an `updated_at = DateTimeField(auto_now=True)` stays stale unless you set it explicitly (for example `update(title=t, updated_at=timezone.now())`). - **`update_or_create()`** goes through `save()`, so its updates do fire `post_save`, unlike `QuerySet.update()`. ## What `QuerySet.delete()` does run Deletion is the asymmetric case. `QuerySet.delete()` does not call the `delete()` method of each instance; an override of `Model.delete()` is skipped. But it does not issue a blind `DELETE` either. It builds a **`Collector`** (in `django/db/models/deletion.py`) and: 1. **collects** the rows to delete and, following `on_delete` rules emulated in Python (`CASCADE`, `SET_NULL`, `PROTECT` and friends), the related rows they affect; 2. inside `transaction.atomic()`, sends **`pre_delete`** for every collected object; 3. applies `SET_NULL`/`SET_DEFAULT`-style field updates and deletes the rows; 4. sends **`post_delete`** for every deleted object, cascaded ones included; 5. returns a tuple: the total number of objects deleted and a dict of counts per model label. The documentation's advice follows from that: to guarantee custom delete logic runs on bulk and cascaded deletes, put it in `pre_delete`/`post_delete` receivers, not in a `delete()` override. ## The fast path and the database-level cascade Collecting means fetching rows into memory, which is costly for large deletes. The collector can skip that and run one `DELETE ... WHERE` when the model has: - **no `pre_delete` or `post_delete` receivers**, - **no relations that need Python-emulated cascades** pointing at it, - no multi-table inheritance parents and no generic relations to clean up. Adding a single `post_delete` receiver for the model therefore turns a one-statement delete into a fetch of every row, which is a classic production surprise. Django 6.1 adds **database-level `on_delete` options** such as `DB_CASCADE`. With those, the database performs the cascade, so Django neither loads the related rows nor sends signals for them, and the per-model counts returned by `delete()` report only the QuerySet's own model. ## Spotting the bypass in code review - A service that calls `Model.objects.filter(...).update(...)` on a model whose `save()` override slugifies, normalises or audits: the rule silently stops applying. - A data migration or management command that `bulk_create()`s rows for a model with `post_save` receivers that build related rows: those rows never appear. - A nightly cleanup `delete()` on a table that gained a `post_delete` receiver: the job now fetches every row before deleting. ## Practical rules - Put invariants that must hold for every write path in the **database** (constraints, defaults) or in code that calls `save()`; do not rely on a `save()` override if anyone uses `update()` or the bulk methods. - When you need `update()`'s efficiency but also side effects, run the side effects explicitly after the call. - Audit delete receivers on large tables: they cost a fetch per deleted row.
- In Django, why can adding a post_delete receiver make a large QuerySet.delete() much slower?The collector's fast path, a single `DELETE ... WHERE`, is only allowed when the model has no delete receivers and no Python-emulated cascades. A receiver needs each instance, so Django fetches every matching row into memory, sends signals per object and deletes by primary key in batches.
- In Django, how do you keep an auto_now updated_at column correct when using QuerySet.update()?Set it yourself in the same call, for example `qs.update(status=s, updated_at=timezone.now())`. `update()` writes only the columns you name and never runs field `pre_save()` hooks, so `auto_now` has no effect there.
saying these in an interview costs you the question
- QuerySet.update() calls save() on each row, so signals fire
- QuerySet.delete() calls each instance's delete() method
- QuerySet.delete() never sends pre_delete or post_delete signals
- update_or_create() skips post_save because it is a bulk method
- bulk_create() sends post_save with created=True for each object