Filter concepts by levelShowing all levels.

Django · Section 86

Soft Delete

Level
advanced
Read
30 min
Concepts
3

Soft delete keeps the row and marks it gone, and everything difficult about it comes from maintaining that fiction consistently. Start with the field: a nullable `deleted_at` beats a boolean, because it answers *when* — which is what a retention policy and any historical question need — while `NULL` reads naturally as "live". Then two managers: `objects` filtered and declared first so ordinary code is safe by default, `all_objects` unfiltered for restore, admin and migrations. Two `delete()` overrides, not one — `QuerySet.delete()` never calls `Model.delete()`, so a model-only override leaves every bulk deletion real, which is precisely where large deletions happen. And `base_manager_name` pointed at the unfiltered manager, because Django uses the base manager to fetch related objects and its documentation warns that filtering there makes it "return incomplete results" — a foreign key to a deleted row would simply fail to resolve. The bulk of the section is the four things that break, each needing a different kind of repair. Unique constraints now count the hidden row, so a customer cannot re-register an email they deleted; the fix is a partial unique index, `UniqueConstraint(condition=Q(deleted_at__isnull=True))`, and emphatically not mangling the stored value, which destroys the data you kept the row for. Foreign keys and reverse relations are never filtered, so `order.line_items.all()` returns deleted items — no configuration fixes this, and an explicit `.alive()` at each traversal site is the ongoing tax. The admin hides deleted rows exactly where an operator needs to see them and its delete action removes rows for real, so point it at `all_objects` and take the delete permission away. Reporting is the dangerous one because it fails silently: nothing errors, the totals are just wrong, and it is discovered months later by someone reconciling against another system. Finally, the two exits. Restore must know the deletion's scope — record a batch id, or a restored customer has no invoices — and must re-check the constraints relaxed while the row was hidden, refusing rather than bypassing. Hard delete needs a stated window and a batched purge, or the table grows forever; and a GDPR Article 17 erasure is not satisfied by a flag. Which is the section's own closing rule: do not add soft delete merely because it seems convenient. An `archived` status, an audit log, or backups are usually what was actually being asked for.

What is true here

  1. One nullable timestamp, two managers, and two delete() overrides.
  2. Related access uses the base manager and is never filtered — that is permanent.
  3. Uniqueness has to become conditional, or deleted rows keep holding values hostage.
  4. The admin should show deleted rows; reporting should not, and nothing enforces that.
  5. Build restore and purge before the delete, or you have a growing table with no undo.

What you will be able to do

  • Implement soft delete so that bulk deletions are soft too
  • Free a deleted email for reuse without corrupting the stored row
  • Spot the reporting query that has been counting deleted rows for months
  • Decide honestly whether this model needs soft delete at all
The same DELETE, and everything that changes downstream

Hard delete

  • +The row is gone; every assumption about absence holds
  • +Uniqueness frees up automatically
  • +Relations cannot return it — there is nothing to return
  • +Reporting cannot count it
  • +And it is not coming back: restore means a backup restore

Soft delete

  • The row remains; four systems still see it
  • Uniqueness must be made conditional by hand
  • Relations return it — base managers do not filter
  • Reporting counts it unless every query says otherwise
  • But restore is a feature, and history survives
  • Hard delete
    • The row is gone; every assumption about absence holds
    • Uniqueness frees up automatically
    • Relations cannot return it — there is nothing to return
    • Reporting cannot count it
    • And it is not coming back: restore means a backup restore
  • Soft delete
    • The row remains; four systems still see it
    • Uniqueness must be made conditional by hand
    • Relations return it — base managers do not filter
    • Reporting counts it unless every query says otherwise
    • But restore is a feature, and history survives

The mechanism

One nullable timestamp, two managers, two `delete()` overrides — and why the base manager must stay unfiltered.

The field, and the manager that hides it

coreintermediate

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.

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.

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

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.

core/soft_delete.pypython
from django.db import models
from django.utils import timezone


class SoftDeleteQuerySet(models.QuerySet):
    def delete(self):
        """Order.objects.filter(...).delete() does NOT call Model.delete().
        Without this override the queryset path really removes the rows."""
        return super().update(deleted_at=timezone.now())

    def hard_delete(self):
        return super().delete()

    def alive(self):
        return self.filter(deleted_at__isnull=True)


class LiveManager(models.Manager.from_queryset(SoftDeleteQuerySet)):
    def get_queryset(self):
        return super().get_queryset().filter(deleted_at__isnull=True)


class SoftDeleteModel(models.Model):
    deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
    deleted_by = models.ForeignKey(
        "auth.User", null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
    )

    objects = LiveManager()                  # declared first → the default
    all_objects = models.Manager.from_queryset(SoftDeleteQuerySet)()

    class Meta:
        abstract = True
        # Related access uses the BASE manager. Pointing it at the unfiltered
        # one is what keeps foreign keys resolvable — the "incomplete results"
        # Django's managers documentation warns about.
        base_manager_name = "all_objects"

    def delete(self, using=None, keep_parents=False, actor=None):
        self.deleted_at = timezone.now()
        self.deleted_by = actor
        self.save(update_fields=["deleted_at", "deleted_by"])

    def restore(self):
        self.deleted_at = None
        self.deleted_by = None
        self.save(update_fields=["deleted_at", "deleted_by"])
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

python
class Order(models.Model):
    def delete(self, *args, **kwargs):
        self.deleted_at = timezone.now(); self.save()

Order.objects.filter(status="draft").delete()   # rows are REALLY gone

Better

python
class SoftDeleteQuerySet(models.QuerySet):
    def delete(self):
        return super().update(deleted_at=timezone.now())
# now both paths are soft

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.

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.

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

Together

python
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
deleted_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)

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

Together

python
class Meta:
    base_manager_name = "all_objects"   # related access resolves; nothing is hidden there

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

Advertisement

What it breaks

Uniqueness, relations, the admin and reporting — four assumptions that the row was gone.

What soft delete breaks, and how to repair each one

coreadvanced

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.

Think of it as

Every one of these is the same bug wearing different clothes: some part of the system was written against "the row is not there" and soft delete makes that false. Working through them in order is worthwhile because the repairs are different in kind. The unique-constraint break is a database-level problem and needs a database-level fix — a partial unique index, which Django spells `UniqueConstraint(fields=[…], condition=Q(deleted_at__isnull=True))`. That makes uniqueness apply only among live rows, so a deleted email frees up while duplicates among live rows stay impossible. Reach for it immediately, because the alternative people invent — mangling the value on deletion, `email = f"{email}.deleted.{pk}"` — corrupts the data you soft-deleted in order to keep. The foreign key break is an ORM-behaviour problem, and there is no configuration that fixes it: related access uses the base manager, so `order.line_items.all()` returns deleted items and `filter(order__reference=…)` joins to deleted orders. The repair is to make the filter explicit on the paths that matter — a `.alive()` queryset method used at the traversal site — and to accept that this is the ongoing tax soft delete charges. The admin break is a defaults problem: `ModelAdmin` uses the default manager for the changelist, which means it hides deleted rows exactly where an operator most needs to see them, and its delete action and the `on_delete` cascade both perform real deletions that never touch your override. Fix it by pointing the admin at the unfiltered manager and giving it explicit soft-delete and restore actions. The reporting break is the most dangerous because it is silent: nothing errors, the numbers are simply wrong, and a `COUNT` that has quietly included cancelled records for six months is discovered by someone reconciling against another system. There is no mechanism to catch it, so the defence is a convention — analytics queries go through the filtered manager, or state the predicate explicitly, and anything written in raw SQL gets a review checklist item. Which is really the argument the section closes with: each of these is affordable, and their sum is why you should not add soft delete because it seems convenient.

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

What we're doing: Repair all four surfaces on one model: partial uniqueness, an explicit live filter for traversal, an admin that shows and soft-deletes, and reporting that states its predicate.

accounts/models.py + accounts/admin.pypython
class Customer(SoftDeleteModel):
    email = models.EmailField()          # NOT unique=True — see below

    class Meta:
        constraints = [
            # A plain unique=True would count the soft-deleted row and stop
            # the customer ever re-registering. A partial index applies the
            # rule only among live rows.
            models.UniqueConstraint(
                fields=["email"],
                condition=models.Q(deleted_at__isnull=True),
                name="uniq_live_customer_email",
            ),
        ]


# --- traversal: the ORM will not do this for you -------------------------
def open_invoices(customer):
    # customer.invoices.all() uses the BASE manager and includes deleted
    # invoices. The filter has to be written at the traversal site.
    return customer.invoices.alive().filter(status="open")


# --- reporting: state the predicate, every time --------------------------
def monthly_revenue(month):
    return (
        Invoice.objects                       # the FILTERED manager, deliberately
        .filter(issued_at__month=month, status="paid")
        .aggregate(total=Sum("total"))["total"] or 0
    )


# --- accounts/admin.py ---------------------------------------------------
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
    list_display = ("email", "deleted_at")
    actions = ["soft_delete", "restore"]

    def get_queryset(self, request):
        # The admin is where an operator needs to SEE deleted rows — it is
        # the restore console. The default manager would hide them.
        return Customer.all_objects.all()

    def has_delete_permission(self, request, obj=None):
        return False                          # the built-in action really deletes

    @admin.action(description="Soft delete selected")
    def soft_delete(self, request, queryset):
        queryset.delete()                     # SoftDeleteQuerySet.delete()

    @admin.action(description="Restore selected")
    def restore(self, request, queryset):
        queryset.update(deleted_at=None, deleted_by=None)
9–13
The partial unique index. `unique=True` and `unique_together` cannot take a condition, so soft delete forces you to express uniqueness as a `UniqueConstraint` — which is the better tool regardless.
20–22
Written out because there is no configuration that fixes it. Related access goes through the base manager, so this filter is the ongoing tax soft delete charges on every traversal that matters.
27
Using `Invoice.objects` here is a decision, not a default — reporting is the surface that fails silently, so the predicate has to be deliberate and reviewable.
39–41
The admin is inverted on purpose: it is the one place that should show deleted rows, because it is where restoring happens.
43–44
Disabling the built-in delete permission is the important half. That action bypasses your queryset override, and `on_delete=CASCADE` will remove related rows for real as well.

Why this works: A deleted email can be reused, traversal is filtered where it needs to be, operators can see and restore, reporting counts only live rows, and the admin can no longer delete anything permanently by accident.

Mangling the unique value on deletion

Wrong

python
def delete(self):
    self.email = f"{self.email}.deleted.{self.pk}"     # frees the constraint
    self.deleted_at = timezone.now()
    self.save()

Better

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

What you see: Restored accounts come back with an email like `ada@example.test.deleted.417`, and any audit or export produced after the deletion contains the mangled value as though it were real.

Why: It destroys the data you soft-deleted in order to keep. The whole justification for soft delete is that the row remains available for restore, audit and reporting; overwriting a field to satisfy a constraint means the row that remains is no longer the row that existed. It also compounds — a second deletion of a restored account mangles the mangled value — and every downstream consumer has to know to strip a suffix. A partial unique index solves the same problem in the database, where uniqueness belongs, and leaves the data untouched.

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.

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

Together

python
class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["email"],
            condition=models.Q(deleted_at__isnull=True),
            name="uniq_live_user_email",
        ),
    ]

Remember: 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`.

See also: the field and the default manager · restore and hard delete policy · choosing the right on delete

Advertisement

Restore, purge, and whether to do this at all

The two exits from the holding state, and the section's own closing rule.

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

standardintermediate

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.

Think of it as

Think of a soft-deleted row as being in a holding state with two exits, and make sure both exist before you build the entrance. The restore exit is where the subtlety is. Clearing `deleted_at` on one row is easy; restoring a *thing* usually is not, because deleting a customer soft-deleted their invoices too, and bringing back the customer alone produces an account with no history. So restore has to know the same scope the deletion knew, which in practice means recording it: a deletion batch id, or a `deleted_at` timestamp shared by everything removed in one action, so restore can select exactly that set. It also has to re-check the constraints that were relaxed while the row was hidden — the whole point of the partial unique index is that somebody else may have taken the email in the meantime, and a restore that ignores that either fails at the database or, worse, is written to bypass it. And restore is an audited administrative action, not a soft one: it makes data visible again, which is a disclosure event. The hard-delete exit is the one most systems never build, and its absence turns soft delete into an unbounded growth problem: tables carrying years of rows nobody can query, indexes bloated with entries that never match a live predicate, and backups growing for no benefit. A purge job with a stated window — "hard-delete rows soft-deleted more than 90 days ago" — closes it, and needs to run in batches for the same reasons any large delete does. Erasure is the case where the policy is not yours to choose: GDPR Article 17 gives a data subject the right to erasure, and a soft-deleted row is still stored personal data, so honouring such a request means real deletion or genuine anonymisation, not a flag. Design that path deliberately and separately from the retention purge. All of which is the argument behind the roadmap's closing rule. Soft delete is right when the domain genuinely has an undo, when regulators require retention, or when accidental deletion is expensive and common. It is wrong when it is adopted as a general safety net, because then you pay the constraint, traversal, admin and reporting costs on every model forever, in exchange for a restore path nobody has ever exercised. The cheaper alternatives are often better: an "archived" status when the domain really means archived, an audit trail when the requirement is knowing what happened, and backups when the requirement is disaster recovery.

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

What we're doing: Restore everything one deletion removed, refusing rather than bypassing when a constraint has since been taken — and record the restore as the administrative action it is.

accounts/services.pypython
def delete_customer(customer, *, actor):
    """One deletion, one batch id — so restore knows exactly what to undo."""
    batch = uuid4()
    with transaction.atomic():
        Invoice.objects.filter(customer=customer).update(
            deleted_at=timezone.now(), deleted_batch=batch
        )
        customer.deleted_at = timezone.now()
        customer.deleted_by = actor
        customer.deleted_batch = batch
        customer.save(update_fields=["deleted_at", "deleted_by", "deleted_batch"])
    record_audit(actor, "customer.deleted", customer, extra={"batch": str(batch)})


def restore_customer(customer, *, actor):
    if customer.deleted_at is None:
        raise ValidationError("This customer is not deleted.")

    # The partial unique index was relaxed while the row was hidden. Somebody
    # may have taken the email since — check BEFORE writing, and refuse.
    taken = Customer.objects.filter(email=customer.email).exclude(pk=customer.pk)
    if taken.exists():
        raise ValidationError(
            f"{customer.email} is now in use by an active customer. "
            f"Change the address on one of them before restoring."
        )

    with transaction.atomic():
        # Restore the whole scope the deletion covered, not just the parent —
        # a customer restored without their invoices is a broken account.
        Invoice.all_objects.filter(deleted_batch=customer.deleted_batch).update(
            deleted_at=None, deleted_batch=None
        )
        customer.deleted_at = None
        customer.deleted_by = None
        customer.deleted_batch = None
        customer.save(update_fields=["deleted_at", "deleted_by", "deleted_batch"])

    # Restoring makes data visible again. That is a disclosure event.
    record_audit(actor, "customer.restored", customer)
3–10
The batch id is the whole design. Without it, restore has to guess the scope — usually by timestamp proximity, which is wrong whenever two deletions happen in the same second.
20–25
Checking and refusing, rather than bypassing. The temptation is to mangle or force the value; refusing keeps a human in a decision that genuinely needs one.
30–32
Restoring the children by batch, in the same transaction as the parent. A partially restored account is worse than one still deleted, because it looks fine.
38–39
Audited as an administrative action, for the same reason the deletion was — restoring returns data to visibility, which is exactly the kind of thing a compliance reviewer asks about.

Why this works: A deletion and its restore cover the same rows, a since-taken email produces a clear refusal instead of corrupted data, and both halves leave an audit trail.

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)

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

Together

python
cutoff = timezone.now() - timedelta(days=90)
while ids := list(Customer.all_objects.filter(deleted_at__lt=cutoff)
                  .values_list("pk", flat=True)[:1000]):
    with transaction.atomic():
        Customer.all_objects.filter(pk__in=ids).hard_delete()

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

Together

python
# "Archived" is a domain state with its own rules, not a hidden row.
status = models.CharField(choices=[("active", …), ("archived", …)])

Remember: 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.

See also: what soft delete breaks · the field and the default manager · what actually needs auditing

Advertisement