skip to content

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?

level: middleimportance: should knowfreq 55%

answer

  1. SQL-level writes skip Python hooks
  2. update(), bulk_create(), bulk_update()
  3. delete() is collected, not raw
  4. per-object delete signals, no Model.delete()

basics

~10 s

QuerySet.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 s

Anything 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 lines
python
from 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

for a junior

Recall that update(), bulk_create() and bulk_update() write straight to SQL and skip save() and its signals.

for a middle

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.

for a senior

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.

for a principal

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