Filter concepts by levelShowing all levels.

Django · Soft Delete

Soft Delete

A row that is gone but still there — and the four systems that disagree

  • deleted_at, not is_deletedNULL means live, and a timestamp supports a purge policy a boolean cannot
  • QuerySet.delete() never calls Model.delete()override both, or every bulk deletion is a real one
  • Uniqueness must become partialUniqueConstraint(condition=Q(deleted_at__isnull=True)) — never mangle the value
  • Related access is never filteredorder.line_items.all() returns deleted rows; .alive() is a permanent tax
  • Reporting fails silentlyno error — just totals that include rows the product calls gone

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

Field shape: what each choice can and cannot answer
FieldAnswers "when?"Supports a purge policyNote
`is_deleted = BooleanField`nonocheapest, and the one people regret
`deleted_at = DateTimeField(null=True)`yesyesthe default recommendation
`deleted_at` + `deleted_by`yesyesadd when a human does the deleting
a `status` field with a `deleted` valuenonoconflates lifecycle with visibility

Which manager runs on which access path

Which manager runs on which access path
AccessManager usedSees deleted rows?
`Order.objects.all()``objects` (filtered)no
`Order.all_objects.all()``all_objects`yes — for restore and admin
`order.line_items.all()`**base manager****yes** — the filter does not apply
`Payment.objects.filter(order__ref=…)`base manager for the join**yes**
`get_object_or_404(Order, pk=pk)``_default_manager`no
a `ForeignKey` attribute (`payment.order`)base manageryes — and it must, or it would raise
The three lines that define a soft-deletable model — and what each one is for

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.

  • 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

python
Order.objects.filter(deleted_at__isnull=True)   # what LiveManager does for you

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.

2.What soft delete breaks, and how to repair each one

Keeping deleted rows breaks four things that assumed they were gone. **Unique constraints** now count the deleted row, so a user cannot re-register an email they deleted. **Foreign keys** still point at hidden rows, and relationship access does not filter — so a soft-deleted line item is still in `order.line_items.all()`. **The admin** shows deleted rows and really deletes them, because it uses managers and mechanisms you did not override. **Reporting** double-counts, because a `SUM` over the table includes rows the product considers gone.

Key features

  • Make uniqueness partial: `UniqueConstraint(fields=[…], condition=Q(deleted_at__isnull=True))`
  • Never mangle a value on delete to dodge a constraint — it corrupts the row you kept
  • Relationship access uses the base manager, so `order.line_items.all()` includes deleted items
  • `ModelAdmin` uses the default manager: the changelist hides deleted rows from the one operator who needs them
  • The admin delete action and `on_delete=CASCADE` both really delete — neither calls your override
  • Reporting fails silently: a `SUM` or `COUNT` over the table includes rows the product calls gone

The four breaks, and the repair for each

The four breaks, and the repair for each
What breaksSymptomRepair
unique constraintcannot re-register a deleted email`UniqueConstraint(condition=Q(deleted_at__isnull=True))`
`unique_together` / `unique=True`same, and it cannot be made conditionalreplace with a `UniqueConstraint` that has a condition
related access`order.line_items.all()` returns deleted itemsan explicit `.alive()` at the traversal site
reverse joins`filter(order__ref=…)` matches deleted ordersadd the predicate to the filter
admin changelistoperators cannot see what they need to restorepoint `get_queryset()` at `all_objects`
admin delete + cascaderows really disappearremove the action; add soft-delete/restore actions
reportingsilently wrong totalsconvention: filtered manager, or state the predicate
One deleted row, four systems that assumed it was gone

The row in the middle is soft-deleted. Each surrounding box is a part of the system written against the assumption that a deleted row is absent — and the label on each arrow is what it does instead.

  • At the centre is a soft-deleted row: a user with the email ada@example.test and a deleted_at timestamp set.
  • Four boxes around it show what still sees the row. A unique constraint still counts it, so re-registering that email fails. Related access through the base manager still returns it. The admin changelist hides it while the admin delete action really removes it. Reporting sums it into totals.
  • Beneath each is the repair: a partial unique constraint conditioned on deleted_at being null; an explicit alive() filter at the traversal site; pointing the admin at the unfiltered manager; and a convention that analytics go through the filtered manager.

Command

python
UniqueConstraint(fields=["email"], condition=Q(deleted_at__isnull=True), name="uniq_live_email")

Four things assumed the row was gone. Uniqueness: make it partial with `UniqueConstraint(condition=Q(deleted_at__isnull=True))` — never mangle the value, which destroys the data you kept. Foreign keys and reverse relations: related access uses the base manager and does not filter, so `.alive()` has to be written at the traversal site, permanently. Admin: point it at `all_objects` (it is the restore console) and disable the real delete action. Reporting: it fails *silently*, so make the predicate a convention. And `on_delete=CASCADE` never calls your override — prefer `PROTECT`.

3.Restore, hard delete, and whether to do this at all

Soft delete is only worth its cost if the rows actually come back and eventually go away. **Restore** needs to be a designed workflow, not just clearing a field — a restored parent whose children stayed deleted is broken in a way nobody notices until later. **Hard delete** needs a stated policy, because "keep everything forever" is a decision even when nobody makes it, and a real erasure request under GDPR Article 17 obliges you to actually remove data. The roadmap's closing rule is the summary: do not add soft delete merely because it seems convenient.

Key features

  • Restore must know the deletion's scope — record a batch id or share one `deleted_at` timestamp
  • Restoring re-enters the uniqueness contest: the email may have been taken while the row was hidden
  • Restore is an audited administrative action — it makes data visible again
  • Without a purge policy, soft-deleted rows accumulate forever and bloat every index on the table
  • GDPR Article 17 erasure requires real deletion or anonymisation — a flag is still stored data
  • The roadmap's rule: do not add soft delete merely because it seems convenient

Two exits from the holding state, and what each one needs

Two exits from the holding state, and what each one needs
ExitTriggerMust handle
**restore**a person, deliberatelyscope, constraint re-check, audit row
**retention purge**a scheduled job, by agebatching, cascade order, an alert on failure
**erasure request**a data subject, by rightreal removal or anonymisation, across every copy
(no exit)nobody built oneunbounded growth — the common case

Is soft delete the right tool here?

Is soft delete the right tool here?
If the requirement isUseRather than
"users can undo a deletion"soft delete, with a real restore UIa flag nobody can act on
"the domain has archived records"a `status` field — it is a state, not a deletionsoft delete
"we must know who changed what"an audit logkeeping every row forever
"we must not lose data to mistakes"backups and `PROTECT`soft delete on every model
"regulators require N years"soft delete **plus** a purge at N yearssoft delete with no purge
"it seems safer"nothing — this is the rule's targetsoft delete
The holding state, and the two exits that have to exist
delete() —scope recordedrestorerequestedconstraint free — restore thewhole scope, and audit ita live row nowholds the valueresolvedby a humanage > retentionwindowerasure request— not optionalno purgejob exists

live deleted_at IS NULL

start

soft-deleted deleted_at set, batch recorded

restore attempted re-check the partial unique index

blocked — the email was taken ask the operator, do not bypass

hard-deleted by retention (90 days)

end

erased or anonymised (GDPR Art. 17)

end

kept forever no policy was ever written

end

  • live deleted_at IS NULL (start)
    • → soft-deleted deleted_at set, batch recorded when delete() — scope recorded
  • soft-deleted deleted_at set, batch recorded
    • → restore attempted re-check the partial unique index when restore requested
    • → hard-deleted by retention (90 days) when age > retention window
    • → erased or anonymised (GDPR Art. 17) when erasure request — not optional
    • → kept forever no policy was ever written when no purge job exists
  • restore attempted re-check the partial unique index
    • → live deleted_at IS NULL when constraint free — restore the whole scope, and audit it
    • → blocked — the email was taken ask the operator, do not bypass when a live row now holds the value
  • blocked — the email was taken ask the operator, do not bypass
    • → soft-deleted deleted_at set, batch recorded when resolved by a human
  • hard-deleted by retention (90 days) (end)
  • erased or anonymised (GDPR Art. 17) (end)
  • kept forever no policy was ever written (end)

Command

python
Customer.all_objects.filter(deleted_batch=batch_id).update(deleted_at=None)

Build both exits before the entrance. Restore needs the deletion's *scope* — record a batch id, or a customer comes back without their invoices — and must re-check the constraints that were relaxed while the row was hidden, refusing rather than bypassing; it is an audited administrative action, because it returns data to visibility. Hard delete needs a stated retention window and a batched purge job, or soft-deleted rows accumulate forever and bloat every index. A GDPR Article 17 erasure is not satisfied by a flag. And the section's own rule: do not add soft delete merely because it seems convenient — an `archived` status, an audit log, or backups are usually the thing actually being asked for.

Remember

  • Set `base_manager_name` to the unfiltered manager, or a foreign key to a deleted row cannot be resolved.
  • `on_delete=CASCADE` never calls your `delete()` override — prefer `PROTECT` on soft-deletable models.
  • Restore needs the deletion's *scope*. Record a batch id, or the customer comes back without their invoices.
  • Without a purge job, soft-deleted rows accumulate forever and bloat every index on the table.

Pro tip

  • Point the admin at `all_objects` and disable its delete permission. The admin is the restore console — it is the one place that *should* show deleted rows.
  • A GDPR Article 17 erasure is not satisfied by a flag: a soft-deleted row is still stored personal data.

Next up

  • State Machines and Workflows
  • Payment and Webhook Workflows
  • Event-Driven Architecture

Sources

  • Managers (base vs default) — docs.djangoproject.com/en/5.2/topics/db/managers/
  • Constraints (UniqueConstraint condition) — docs.djangoproject.com/en/5.2/ref/models/constraints/
  • Right to erasure — GDPR Article 17
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.