1.The field, and the manager that hides it
Soft 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.
Key features
- Prefer `deleted_at = DateTimeField(null=True, blank=True, db_index=True)` over a boolean
- `NULL` means live, so `filter(deleted_at__isnull=True)` is the live-row predicate
- Two managers: `objects` filtered, `all_objects` unfiltered — and name them for what they do
- Django warns you "should not override `get_queryset()` to filter out any rows" on a base manager
- Set `base_manager_name = "all_objects"`, so related access resolves rather than returning incomplete results
- Override `delete()` on the model *and* the queryset — deleting through a queryset does not call the model method
Field shape: what each choice can and cannot answer
Which manager runs on which access path
- 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.
Command
Order.objects.filter(deleted_at__isnull=True) # what LiveManager does for youUse 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.