The field, and the manager that hides it
coreintermediateSoft delete replaces removal with a flag: the row stays, and a field marks it as gone. Prefer a nullable `deleted_at` timestamp over a boolean, because it answers *when* as well as *whether*, and `deleted_at__isnull=True` is exactly as easy to filter on. A manager whose `get_queryset()` excludes deleted rows then makes the flag mostly invisible to application code. Django's documentation is explicit that this filtering does not apply to relationship access — which is the constraint the rest of this section is about.
Think of it as
You are choosing to keep a row that the domain considers gone, and everything that follows is the cost of maintaining that fiction consistently. The fiction is maintained in one place — the default manager — and leaks everywhere that place is not consulted. Getting the shape right starts with the field. A boolean `is_deleted` tells you a row is gone but not when, so it cannot support a retention policy ("purge 90 days after deletion"), cannot answer "was this visible on the invoice date?", and cannot distinguish a deletion from a data-migration mistake. A nullable `deleted_at` gives you all three for the same storage, and `NULL` means "live", which reads naturally in a query and indexes well as a partial index. Add `deleted_by` when a human performs the deletion, for the same reason audit rows have an actor. The manager is the second decision, and Django pushes back on the obvious approach for a reason worth understanding rather than working around. The documentation warns against overriding `get_queryset()` to filter out rows, because the *base* manager is what Django uses to fetch related objects, and one that hides rows makes Django "return incomplete results" — a foreign key pointing at a soft-deleted row would fail to resolve, and cascades and `dumpdata` would see an incomplete picture. The resolution is to have two managers and to be deliberate about which is which: `objects` filters, so ordinary code is safe by default; `all_objects` does not, so restore flows, admin tooling and migrations can see everything; and `base_manager_name` points at the unfiltered one so relationship traversal keeps working. That last line is not a workaround, it is the honest configuration — related access was going to use an unfiltered manager anyway, and naming it makes the behaviour visible instead of surprising. The consequence to carry into the next concept is that `order.line_items.all()` will happily return soft-deleted line items, and no manager you write changes that.
What we're doing: A reusable soft-delete base class where both the model and the queryset honour `delete()`, and restoring is a first-class operation.
- 5–9
- The override people forget. `Model.delete()` is not called by `QuerySet.delete()`, so a model-only override leaves the bulk path really deleting rows — the one case where soft delete silently is not soft.
- 11–12
- A named `hard_delete` keeps genuine removal available and greppable. Retention purges and GDPR erasure both need it, and it should never be the accidental path.
- 18–20
- The filtering happens in one place. Everything else in the codebase writes ordinary queries and gets live rows.
- 29–30
- Declaration order decides the default manager, so `objects` must come first. `all_objects` is built from the same queryset class so `hard_delete()` and `alive()` are available on both.
- 36–37
- Without this, a `ForeignKey` to a soft-deleted row cannot be resolved and raises on attribute access — the documented failure mode of filtering in a base manager.
- 40–43
- `update_fields` keeps the write to two columns, which matters because soft deletion is often applied in bulk and a full-row update would rewrite everything.
Why this works: Both delete paths become soft, related access still resolves, restoring is an ordinary method, and real deletion remains available under a name nobody types by accident.
Overriding `Model.delete()` and nothing else
Wrong
Better
What you see: Soft delete works everywhere in the UI and then a management command or an admin bulk action permanently removes rows — discovered when a restore is requested and there is nothing to restore.
Why: `QuerySet.delete()` issues SQL directly; it does not instantiate each object and call `Model.delete()`. So a model-only override covers the single-object path and misses every bulk path, which is exactly where large deletions happen. The two overrides have to exist together, and the queryset one is the more important of the pair. Note the same asymmetry appears in cascades: rows removed by `on_delete=CASCADE` are deleted by the database or the collector, not by your method.
- Whole: class Order(models.Model): deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) objects = LiveManager() all_objects = models.Manager() class Meta: base_manager_name = "all_objects"
- deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) — When, not just whether: NULL means live. A timestamp supports a purge policy and answers "was this visible in March?", which a boolean cannot.
- objects = LiveManager() — The default, filtered: Declared first, so it is the default manager. Ordinary code never sees a deleted row without asking for one.
- all_objects = models.Manager() — The deliberate escape hatch: Restore flows, admin tooling and migrations need every row. Naming it makes each use greppable rather than accidental.
- base_manager_name = "all_objects" — The honest configuration: Related access uses the base manager whatever you do. Pointing it at the unfiltered manager avoids the "incomplete results" the docs warn about.
Field shape: what each choice can and cannot answer
Together
Which manager runs on which access path
Together
Remember: Use a nullable `deleted_at` rather than a boolean — it answers *when*, which is what a purge policy and any historical question need. Two managers: `objects` filtered and declared first, `all_objects` unfiltered for restore, admin and migrations. Override `delete()` on **both** the model and the queryset, because `QuerySet.delete()` never calls `Model.delete()` and that is where bulk deletions happen. And set `base_manager_name` to the unfiltered manager: related access uses the base manager whatever you do, and filtering there makes Django return incomplete results.
See also: what soft delete breaks · restore and hard delete policy · default vs base manager

