In Django, is on_delete=CASCADE enforced by Python or by the database, and what changes when you switch to Django 6.1's DB_CASCADE?
answer
- who issues the child DELETEs
- the deletion collector
- a constraint with no ON DELETE action
- signals and mixing along a chain
basics
~20 sClassic CASCADE runs in Python: Django's deletion collector finds the related rows and deletes them itself, sending delete signals. Django 6.1's DB_CASCADE writes ON DELETE CASCADE into the foreign key instead, so the database deletes children without Django fetching them or sending signals.
solid answer
~40 s`CASCADE`, like `PROTECT`, `RESTRICT` and the `SET_*` options, is emulated in Python. When you call `delete()` on an instance or a queryset, Django's deletion collector queries the related rows, sends `pre_delete` and `post_delete` for each, and issues the `DELETE` statements in dependency order inside one transaction; the foreign key it created carries no `ON DELETE` action. Django 6.1 adds `DB_CASCADE`, `DB_SET_NULL` and `DB_SET_DEFAULT`, which are written into the constraint as the SQL `ON DELETE` clause through a migration. Django then skips fetching the children, so large deletes get cheaper, but no delete signals fire for rows the database removes, and the system checks reject mixing database and Python variants along related models.
code
python · 13 linesfrom django.db import models
class Book(models.Model):
title = models.CharField(max_length=300)
class Authorship(models.Model):
# Django 6.1: the database deletes these rows when the book goes
book = models.ForeignKey(Book, on_delete=models.DB_CASCADE)
# DO_NOTHING: the plain constraint refuses deleting an author who still has books
author = models.ForeignKey("catalog.Author", on_delete=models.DO_NOTHING)
position = models.PositiveSmallIntegerField()go deeper
Remember that Django's usual CASCADE is carried out by Django itself when you delete through the ORM, not by the database.
Explain the collector: it loads related rows, sends delete signals and issues the DELETEs in one transaction, and a raw SQL delete bypasses it.
Weigh DB_CASCADE against CASCADE for a large hierarchy: fewer queries, but no signals for cascaded rows, a constraint migration and the no-mixing rule.
Set a policy for which relations the database owns: where other clients write the tables, where signals carry side effects, and how the choice is recorded for readers.
## The classic options live in Python Every `on_delete` option available before Django 6.1 except `DO_NOTHING` is **emulated in Python**. The foreign key constraint Django creates in a migration carries **no `ON DELETE` action**; the database only knows that the key must reference an existing row. When you call `book.delete()` or `Book.objects.filter(...).delete()`, Django builds a **deletion collector** (`django.db.models.deletion.Collector`). It: 1. Starts from the rows you asked to delete. 2. For every relation pointing at them, applies that field's `on_delete`: `CASCADE` adds the related rows to the delete set and recurses, `PROTECT` raises, `RESTRICT` records, `SET_NULL`/`SET_DEFAULT`/`SET()` schedule an `UPDATE`. 3. Sends `pre_delete` for each collected instance, runs the updates and the `DELETE` statements in dependency order, then sends `post_delete`, all inside one `transaction.atomic` block. When a related model has no signal receivers and nothing further to cascade into, the collector takes a **fast-delete** path and issues a single `DELETE ... WHERE <fk> IN (...)` without loading those rows. Otherwise it has to fetch them. Note that the related models' own `delete()` methods are **not** called during a cascade; only the signals are sent. The consequence interviewers look for: the rule is enforced only when the delete goes through the ORM. A raw SQL `DELETE` of a publisher meets a plain foreign key and is rejected by the database; nothing cascades. ## DO_NOTHING: the old escape hatch `DO_NOTHING` tells the collector to skip the relation entirely. With the default constraint, the database then refuses the delete with an `IntegrityError`. Before 6.1, teams that wanted the database to cascade used `DO_NOTHING` plus a hand-written `RunSQL` migration that recreated the constraint with `ON DELETE CASCADE`. ## Django 6.1: database-level options Django 6.1 makes that pattern first-class. `on_delete` now accepts three **database-level** options: | Option | SQL clause | Extra requirement | |---|---|---| | `DB_CASCADE` | `ON DELETE CASCADE` | none | | `DB_SET_NULL` | `ON DELETE SET NULL` | `null=True` (check `fields.E320`) | | `DB_SET_DEFAULT` | `ON DELETE SET DEFAULT` | `db_default` (check `fields.E322`); not supported on MySQL or MariaDB | Because the rule is now part of the column definition, switching a field from `CASCADE` to `DB_CASCADE` produces a migration that alters the constraint. ## What changes when the database does the work - **No fetching.** The collector skips these relations, so deleting a publisher with a large back catalog does not load every book first. - **No signals for database-deleted rows.** Django never sees those rows, so `pre_delete` and `post_delete` are not sent for them. Anything hanging off those signals (search-index updates, file clean-up) silently stops for cascaded rows. - **Counts.** The per-model counts `delete()` returns come from the collector, so they cover the rows Django itself deleted. - **Raw SQL now cascades too.** The rule holds for any client of the database, not only the ORM. - **No mixing along a chain.** Django's docs state that the database variants cannot be mixed with Python variants (other than `DO_NOTHING`) in the same model or in models related to each other; the system check `fields.E323` enforces this. - **Backend support is checked.** A backend that lacks a variant reports `fields.E324` and suggests the Python equivalent. ## A checklist before switching 1. Search for `pre_delete` and `post_delete` receivers on the child model; each one stops firing for rows the database cascades. 2. Check every model related to the ones you change: the database and Python variants must not be mixed along the chain, apart from `DO_NOTHING`. 3. For `DB_SET_NULL`, confirm `null=True`; for `DB_SET_DEFAULT`, confirm a `db_default` and a backend that supports it. 4. Generate and review the migration: it recreates the foreign key constraint, which on a large table is a schema change to schedule like any other. 5. Re-test the flows that relied on the deleted-row counts or on side effects of the cascade. ## How to decide - Keep **Python-level** options when you rely on delete signals, on `PROTECT`/`RESTRICT` (which have no database variant), or on the admin's familiar behaviour. - Consider **database-level** options for large parent/child hierarchies where fetching children is the cost, where other clients also delete rows, and where nothing depends on per-row delete signals. - Either way, write the decision down: the same word "cascade" now means two different executions, and a reader of the model has to know which one runs.
- A search index is updated from a post_delete receiver on Authorship. What breaks after switching Authorship.book to DB_CASCADE?When a book is deleted, the database removes its Authorship rows without Django loading them, so no post_delete is sent for them and the index keeps stale entries. Either keep Python-level CASCADE for that relation, or move the clean-up to the parent's delete, for example re-indexing from the book's own delete path.
- Why does a raw SQL DELETE of a publisher fail when Book.publisher uses Python-level CASCADE?Python-level CASCADE exists only inside Django's collector. The constraint in the database has no ON DELETE action, so a raw DELETE of a referenced publisher violates it and the database rejects the statement. The ORM never got the chance to delete the books first.
saying these in an interview costs you the question
- Django's CASCADE adds ON DELETE CASCADE to the foreign key constraint.
- DB_CASCADE still sends pre_delete and post_delete for every cascaded row.
- A raw SQL DELETE triggers Django's Python-level cascade.
- Database and Python on_delete variants can be mixed freely along related models.
- A cascade calls each related model's delete() method.