skip to content

A Django fleet app stores Car and Van as multi-table children of Vehicle, and bulk imports, deletes and row locks misbehave; which write costs does multi-table inheritance add?

level: seniorimportance: should knowfreq 32%

answer

  1. one row per level
  2. the bulk path refuses
  3. deleting a child deletes more
  4. locking only what you name

basics

~20 s

Each child save writes one row per level inside a transaction, bulk_create() raises ValueError for multi-table children, bulk_update() adds a query per ancestor, deleting a child also deletes its parent row unless keep_parents=True, and select_for_update(of=...) must name the parent link.

solid answer

~40 s

Every level of a multi-table hierarchy is its own row, and writes pay for it. `Van.save()` writes the vehicle row then the van row, so Django wraps the pair in a transaction. `Van.objects.bulk_create()` raises `ValueError: Can't bulk create a multi-table inherited model`, so imports fall back to per-object saves, and `bulk_update()` issues an extra query per ancestor for inherited fields. `van.delete()` also deletes the vehicle row unless you pass `keep_parents=True`, and deleting a `Vehicle` cascades to its van row through `vehicle_ptr`. When locking with `select_for_update(of=("self",))`, only the van row is locked; add `"vehicle_ptr"` to `of` to lock the parent. If these costs dominate, an abstract base or one concrete table with a `kind` column usually serves better.

code

python · 19 lines
python
from django.db import transaction

from fleet.models import Van

# Van.objects.bulk_create([...]) raises:
# ValueError: Can't bulk create a multi-table inherited model
with transaction.atomic():
    for row in rows:
        Van.objects.create(plate=row["plate"], daily_rate=row["rate"],
                           cargo_volume_m3=row["volume"])  # 2 INSERTs each

# Remove only the van row; the vehicle row and its bookings stay.
Van.objects.get(plate="VN-042").delete(keep_parents=True)

# Lock both tables' rows, not just the van row.
with transaction.atomic():
    van = Van.objects.select_for_update(of=("self", "vehicle_ptr")).get(plate="VN-007")
    van.daily_rate = 95
    van.save()

go deeper

for a junior

Know that a multi-table child is stored as two rows, so saving and deleting it touches both tables.

for a middle

Name the concrete behaviours: one INSERT per level, bulk_create raising ValueError, and delete removing the parent unless keep_parents=True.

for a senior

Diagnose the production symptoms, such as slow imports, vanished bookings and half-locked rows, trace each to the parent link, and propose the fix or an alternative design.

for a principal

Weigh rewriting a live hierarchy against living with its write costs, and set the evidence bar, measured statement counts and lock contention, before approving the migration.

## Where the second table shows up In Django **multi-table inheritance**, a `Van` is a row in `vehicle` plus a row in `van`, linked by the child's primary key `vehicle_ptr_id`. Reads pay a JOIN; writes pay in less obvious ways: | Operation | Plain model | Multi-table child | |---|---|---| | `save()` of a new object | one `INSERT` | one `INSERT` per level, inside a transaction | | `bulk_create()` | one batched statement | `ValueError: Can't bulk create a multi-table inherited model` | | `bulk_update()` on inherited fields | one statement per batch | an extra query per ancestor | | `instance.delete()` | deletes its row | deletes the child row **and** the parent row | | `select_for_update(of=("self",))` | locks the row | locks the child row only | ## Saving: a transaction you did not write `Model.save()` on a multi-table child first saves every parent (`_save_parents`), copies each parent's key into the link column, then saves the child's own table. Because that takes more than one statement, Django runs it inside `transaction.atomic(savepoint=False)`, so a failure in the child `INSERT` rolls back the parent `INSERT`. Correct — but an import of 50,000 vans issues at least 100,000 statements, and each pair holds its transaction open across two round trips. Code that overrides `save()` on the child also runs once per object, so any per-save logic multiplies with the row count. ## Bulk writes - **`bulk_create()`** checks whether every parent shares the model's concrete table. For a multi-table child it does not, and the call raises `ValueError` before touching the database. Proxies of a single concrete model pass the check. - **`bulk_update()`** works, but updating fields defined on an ancestor costs an extra query per ancestor. The practical fallback for imports is a loop of `save()` calls inside one `transaction.atomic()` block, or loading the parent table with `bulk_create()` and the child table with raw SQL — which bypasses the ORM's guarantees and belongs to a separate discussion. ## Deletes 1. **Deleting a child deletes its parent.** `van.delete()`, and `Van.objects.filter(...).delete()`, collect the parent `Vehicle` rows as well; the rental history attached to the vehicle row goes with it, subject to that relation's `on_delete`. Pass **`keep_parents=True`** to `Model.delete()` to remove only the child's data, for example when a van is converted into a plain vehicle. 2. **Deleting a parent deletes its child.** The implicit link is `on_delete=models.CASCADE`, so `vehicle.delete()` removes the van row. Both are logical, but both surprise a developer who thinks of `Van` as one row. ## Locking `select_for_update()` with no `of` argument locks every row the query selects — for a child query that includes the joined parent rows. Once you narrow it with `of=("self",)`, only the van table's rows are locked. To lock the vehicle row as well, name the parent link: `Van.objects.select_for_update(of=("self", "vehicle_ptr"))`. A booking service that updates `Vehicle.status` while holding a lock only on `van` can race with another writer on the same vehicle row. ## Diagnosing the symptoms Each complaint in a fleet app maps back to the parent link: 1. **Slow imports** — count statements for one batch (with `DEBUG = True`, `django.db.connection.queries` lists them). Two `INSERT`s per van confirm the per-level cost; the fix is batching inside one transaction or changing the design, not tuning the database. 2. **Vanished bookings** — trace the delete: removing a `Van` collected its `Vehicle` row, and the bookings' own `on_delete` decided the rest. A `PROTECT` rule on `Booking.vehicle` would have raised `ProtectedError` instead of deleting silently. 3. **Lost updates under concurrency** — check every `select_for_update(of=...)` for the parent link. A lock on the van row alone does not serialize two writers of `daily_rate`, which lives in the vehicle table. ## When to move away Multi-table inheritance is a reasonable choice when subtype columns are many, mostly non-null and distinct. When write paths suffer, the alternatives are: - an **abstract base**: one self-contained table per type, bulk operations work, but no single `ForeignKey` target; - **one concrete `Vehicle`** with a `kind` field, nullable subtype columns and conditional constraints, with proxies per kind for behaviour. Measure first — count statements per import and check which paths lock what — then decide; changing a live hierarchy is a data migration, not a one-line edit.

  • Does Django's bulk_create() work for a proxy model of a single concrete table?
    Yes. The guard in `bulk_create()` rejects models whose parents have a different concrete model; a proxy's parent shares its concrete table, so the call proceeds as for the parent model.
  • When is delete(keep_parents=True) the right call?
    When a row stops being the subtype but should remain a parent: a van converted into a plain vehicle, keeping its bookings. It removes only the child's data; without it, `delete()` also removes the vehicle row and whatever cascades from it.

saying these in an interview costs you the question

  • bulk_create() works for multi-table children; it only skips the parent table.
  • Deleting a Van leaves its Vehicle row in place by default.
  • select_for_update(of=('self',)) on Van locks the vehicle row too.
  • Saving a multi-table child is a single INSERT into the child table.
  • A multi-table child save can leave an orphan parent row if the child insert fails.