Soft delete¶
dorm.contrib.softdelete swaps the default DELETE FROM for a
timestamp-based "soft delete": rows stay in the table but get a
deleted_at column set to the current UTC time. The default manager
hides them automatically; opt-in managers expose them when needed.
It's a contrib module (not core) because soft delete carries trade- offs that some projects can't accept — read the caveats before adopting it project-wide.
Quick start¶
from dorm.contrib.softdelete import SoftDeleteModel
import dorm
class Article(SoftDeleteModel):
title = dorm.CharField(max_length=200)
body = dorm.TextField()
class Meta:
db_table = "articles"
Inherit from SoftDeleteModel instead of dorm.Model. The mixin
contributes:
- A
deleted_atDateTimeField(null=True, blank=True, db_index=True) - Three managers:
objects,all_objects,deleted_objects delete(hard=False)/restore()instance methods (sync + async)
You still need to run dorm makemigrations / dorm migrate so the
table picks up the deleted_at column.
Three managers¶
Article.objects # only live rows (deleted_at IS NULL)
Article.all_objects # everything, including soft-deleted
Article.deleted_objects # only soft-deleted (deleted_at IS NOT NULL)
The default objects is a SoftDeleteManager whose get_queryset
filters deleted_at__isnull=True. Every method on the queryset
inherits that filter — .filter(), .count(), .exists(),
aggregates, async iteration, prefetch, and so on. You don't have
to remember to add .alive() everywhere; that's the whole point.
all_objects and deleted_objects are intended for admin tooling,
audit dashboards, GDPR exports, and undelete flows.
Deleting¶
article = Article.objects.get(pk=1)
article.delete() # UPDATE … SET deleted_at = now()
article.delete(hard=True) # actual DELETE FROM …
delete() is a soft delete by default. Pass hard=True to bypass
the soft path entirely — useful for GDPR purges, abuse cleanup, or
periodic compaction of long-deleted rows.
The async equivalent works the same way:
delete() returns a (total, by_model) tuple matching the regular
Model.delete contract, so existing call sites keep working:
Restoring¶
article = Article.deleted_objects.get(pk=1)
article.restore()
# Now visible again to Article.objects
restore() clears the deleted_at slot and saves. No-op if the
row was never soft-deleted. Async: await article.arestore().
Custom managers¶
SoftDeleteManager is just a Manager with one extra filter, so you
can subclass it for custom default scoping:
from dorm.contrib.softdelete import SoftDeleteManager
class TenantSoftDeleteManager(SoftDeleteManager):
def get_queryset(self):
from .middleware import current_tenant_id
return super().get_queryset().filter(tenant_id=current_tenant_id())
class Article(SoftDeleteModel):
title = dorm.CharField(max_length=200)
tenant_id = dorm.IntegerField(db_index=True)
objects = TenantSoftDeleteManager()
# all_objects / deleted_objects inherited from SoftDeleteModel
Caveats¶
on_delete=CASCADE does NOT cascade through soft deletes¶
class Author(SoftDeleteModel):
name = dorm.CharField(max_length=100)
class Article(SoftDeleteModel):
title = dorm.CharField(max_length=200)
author = dorm.ForeignKey(Author, on_delete=dorm.CASCADE)
When you author.delete() (soft), the author's deleted_at is set
but the Article rows stay live and visible to Article.objects —
they still have the FK pointing at the now-soft-deleted author.
If you need cascading soft deletes, override delete() in the
parent to walk relations explicitly:
class Author(SoftDeleteModel):
name = dorm.CharField(max_length=100)
def delete(self, using="default", *, hard=False):
if not hard:
for art in self.article_set.all():
art.delete()
return super().delete(using=using, hard=hard)
UNIQUE constraints don't know about deleted_at¶
A unique=True column rejects re-inserting a value that matches a
soft-deleted row. If you need "unique among live rows only", create
a partial index at the schema level:
Add this through a RunSQL migration — the autodetector doesn't
emit partial indexes yet.
Foreign keys don't know about deleted_at either¶
A FK pointing at a soft-deleted row stays valid. Reading
article.author returns the soft-deleted author instance. Code that
assumes "if I can dereference the FK, the parent is alive" breaks
silently. Either:
- Filter explicitly:
Article.objects.filter(author__deleted_at__isnull=True) - Cascade soft deletes (see above)
- Use a
Q(...)mixin in the queryset
Disk usage¶
Soft-deleted rows stay on disk forever unless you periodically purge them. For high-churn tables (sessions, events) this can balloon. A common pattern:
# Run nightly via cron / Celery beat:
threshold = datetime.now(timezone.utc) - timedelta(days=90)
Article.deleted_objects.filter(deleted_at__lt=threshold).delete(hard=True)
Testing¶
SoftDeleteModel plays nicely with dorm.test.transactional_db:
each test starts in a transaction that rolls back, so soft deletes
in one test don't leak to the next.
def test_soft_delete_hides(transactional_db):
a = Article.objects.create(title="x")
a.delete()
assert not Article.objects.filter(pk=a.pk).exists()
assert Article.deleted_objects.filter(pk=a.pk).exists()
API reference¶
SoftDeleteModel— abstract model. Inherit instead ofdorm.Model. Contributesdeleted_atfield and the three managers.SoftDeleteManager— manager filteringdeleted_at IS NULL. Subclass for custom default scoping.SoftDeleteModel.delete(using="default", *, hard=False)— soft delete by default;hard=Truebypasses to a realDELETE.SoftDeleteModel.adelete(...)— async counterpart, same args.SoftDeleteModel.restore(using="default")— clearsdeleted_at; no-op if the row wasn't soft-deleted.SoftDeleteModel.arestore(...)— async counterpart.