Soft delete¶
Meta.soft_delete_field turns delete() into marking the row deleted: the row stays in the table, and
every query leaves it out unless it asks for deleted rows.
class Book(Model):
title = fields.CharField(max_length=200)
deleted_at = fields.DatetimeField(null=True)
class Meta:
soft_delete_field = "deleted_at"
The field must exist on the model and allow None.
Deleting: delete()¶
delete()becomes anUPDATEsettingdeleted_at = now(), run in the same transaction as cascading that same soft-delete decision to related rows — instead of a realDELETE. This applies toQuerySet.filter(...).delete()(bulk delete) too, not just a single instance'sdelete(): a model nothing references converts to one bulkUPDATE; otherwise the matched rows and their cascade are soft-deleted in batches — a few queries per wave of related rows, not per row. Every row onedelete()soft-deletes, cascaded ones included, gets the samedeleted_at. The rows being deleted are locked first (SELECT ... FOR NO KEY UPDATEon PostgreSQL): a row anotherdelete()soft-deleted after this instance or queryset read it — a stale instance, or two deletes racing — is left alone, keeps its originaldeleted_at, isn't cascaded again and isn't counted. A stale instance takes the row'sdeleted_atinstead.- A row the cascade reaches whose model has no
soft_delete_fieldof its own is kept, still pointing at the deleted row, and so are the rows of auto-generated M2M through tables: they read as no related row while it is deleted (see What queries see) and point at it again once it is restored.Meta.soft_delete_hard_cascade = Trueon the model being soft-deleted makes its soft delete really delete them instead, as a hard delete would —restore()can't bring them back then. Either waySET_NULL/SET_DEFAULTrelations are updated andPROTECT/RESTRICTones block the delete. .delete()of a queryset soft-deletes only the rows that are still live: a row that is already deleted keeps its originaldeleted_atand isn't counted, exactly asinstance.delete()leaves an already-deleted instance alone (soonly_deleted().delete()returns0). The instance check readsdeleted_atfrom the database when.only()/.defer()left it unloaded.- A cascade reaches related rows of every tenant, and deletes them whether they are soft-deleted or
removed with a real
DELETE— the activeTenancy.scope()is only checked against the instancedelete()is called on. A related model that overridesdelete()has that override called insideTenancy.scope()of the row's own tenant. - After
delete()/restore(),get_dirty_fields()doesn't report theoptimistic_lock_fieldandauto_nowfields the soft-deleteUPDATEwrote along withsoft_delete_field.
Restoring: restore()¶
restore() reverses the delete, setting the field back to None. By default only that row comes
back. restore(cascade=True) also restores the rows its soft delete removed along with it: every row
on_delete=CASCADE leads to that still carries this row's deletion time — the cascade stamps one
time on everything it removes, so that time identifies the delete. A row soft-deleted on its own, before
or after, stays deleted, and so do the rows below it; values SET_NULL/SET_DEFAULT overwrote are not
brought back.
Deleting for real: hard_delete()¶
hard_delete() removes the row for real, whether it is live or already soft-deleted (emptying a
trash); related rows follow their on_delete as a real DELETE would — an on_delete=CASCADE row is
deleted for real too, also of a model with a soft_delete_field and through a db_constraint=False
relation. A delete of a model without a soft_delete_field cascades the same way.
QuerySet.hard_delete() does the same for every row a queryset matches:
What queries see¶
- The default manager adds
WHERE deleted_at IS NULLto every query (.filter(),.all(), ...). - A filter from another model across a reverse relation skips soft-deleted related rows too, whether
nested (
Author.objects.filter(books__title=...)) or on the relation itself (books__isnull=...,books=book,books__in=[...], and theirexclude()forms). - A forward FK/O2O pointing at a soft-deleted row (or at a row of another tenant, or one a custom
Meta.managerfilters out — see Multi-tenancy) reads as no related row:book.author,select_related()/prefetch_related()andvalues("author__name")giveNone, a filter through it (Book.objects.filter(author__name=...)) doesn't match it, and the relation-level null checks agree —author__isnull=True(andauthor=None,author__not_isnull=False) matches such a book,author__isnull=Falsedoesn't; so doesauthor__name__isnull=True(author__name=None), since a hidden target has no visible columns. The key column itself keeps comparing the stored value:author_id__isnull,author=objandauthor_id=...see the key as written, deleted target or not. Withinclude_deleted()/only_deleted()on the filtered queryset the target is visible again, soauthor__isnullis a plain key check there.
Model.objects.include_deleted() returns a QuerySet that skips that filter:
Model.objects.only_deleted() / QuerySet.only_deleted() return only the soft-deleted rows
(WHERE deleted_at IS NOT NULL) — a trash view to restore from:
only_deleted() and include_deleted() replace each other — the last call wins
(Book.objects.only_deleted().include_deleted() sees every row). Like include_deleted(), it keeps the
Meta.tenant_field filter (chain .all_tenants() to drop it) and lets relations reached from the query
(select_related()/prefetch_related()/a relation__field filter) see deleted rows too. It works on a
related manager's queryset as well: author.books.all().only_deleted().
Writing soft_delete_field¶
QuerySet.update(...),bulk_update(),save(update_fields=[...]),merge().when_matched(update=...)andbulk_create(update_fields=[...])refuse to writesoft_delete_field(QueryError) — go through.delete()/.restore()instead, so the cascade logic runs. A plainsave()never writes it either.- An upsert (
bulk_create(update_fields=..., on_conflict=...)) that conflicts with a soft-deleted row updates that row's other columns but leaves it deleted —restore()it to bring it back. So dosave()andbulk_update()of instances read before their rows were soft-deleted elsewhere: both match a row by its key alone, as the instance was read; anoptimistic_lock_fieldmakes them raiseStaleObjectErrorinstead. create()andbulk_create()do acceptsoft_delete_fieldandMeta.optimistic_lock_fieldvalues, so a row can be inserted already deleted or at a given version — for importing or restoring data that was deleted or versioned elsewhere.update(),save()andbulk_update()don't write either:update()/bulk_update()raiseQueryError,save()raises for a changedsoft_delete_fieldand treatsoptimistic_lock_fieldas the version to check against. Don't pass a request's data straight intocreate(**data)/bulk_create()— pick the allowed fields first, or a client can insert a hidden (soft-deleted) row or preset its version.
Unique among live rows¶
hare does not rewrite a unique=True field or a Meta.constraints entry to exclude soft-deleted
rows: a deleted book still holds its ISBN. For "unique among non-deleted rows", add the condition
explicitly:
from hare import Q
from hare.ddl import UniqueConstraint
class Book(Model):
isbn = fields.CharField(max_length=13)
deleted_at = fields.DatetimeField(null=True)
class Meta:
soft_delete_field = "deleted_at"
constraints = (UniqueConstraint(fields=("isbn",), condition=Q(deleted_at__isnull=True)),)
With optimistic_lock_field¶
soft_delete_field and optimistic_lock_field combine
on one model. A soft delete checks each row against the version the delete read it at and bumps it once;
a concurrent change raises StaleObjectError, carrying .model/.pk/ .expected_version — see
Exceptions. A row soft-deleted meanwhile is the exception: deleting it again is
a no-op, as for any soft-deleted row — the instance takes the row's deletion time, whatever version the
row reached.
The instance after a failed write¶
save()/delete()/restore() update auto_now/auto_now_add and soft_delete_field on the Python
instance eagerly, before the write's outcome is known. If the write then fails — a StaleObjectError
from an optimistic_lock_field conflict, or any other exception mid-write — those in-memory values are
rolled back to match what's actually in the database, so the instance never ends up looking saved or
deleted when it wasn't.