python 114 lines · 3 tabs

Soft-Delete in Django with a Custom Manager and QuerySet

Shared by codesnips Jul 2026
3 tabs
from django.db import models
from django.utils import timezone


class SoftDeleteQuerySet(models.QuerySet):
    def delete(self):
        return super().update(deleted_at=timezone.now())

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

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

    def dead(self):
        return self.filter(deleted_at__isnull=False)


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


class AllObjectsManager(models.Manager.from_queryset(SoftDeleteQuerySet)):
    def get_queryset(self):
        return super().get_queryset()


class SoftDeleteModel(models.Model):
    deleted_at = models.DateTimeField(null=True, blank=True, db_index=True, editable=False)

    objects = SoftDeleteManager()
    all_objects = AllObjectsManager()

    class Meta:
        abstract = True

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

    def hard_delete(self, using=None, keep_parents=False):
        return super().delete(using=using, keep_parents=keep_parents)

    def restore(self):
        self.deleted_at = None
        self.save(update_fields=["deleted_at"])

    @property
    def is_deleted(self):
        return self.deleted_at is not None
3 files · python Explain with highlit

Soft-deleting means marking a row as gone instead of issuing a DELETE, which preserves history, keeps foreign keys intact, and makes recovery trivial. The challenge in Django is making the rest of the codebase behave as if deleted rows do not exist, without sprinkling .filter(deleted_at__isnull=True) everywhere. This snippet solves that with a custom QuerySet, a manager built from it, and an abstract base model that ties them together.

In soft_delete.py, SoftDeleteQuerySet overrides delete() so calling it on a queryset performs a bulk UPDATE that stamps deleted_at, returning a count to mimic the real API. It also adds alive() and dead() scopes and a hard_delete() escape hatch that calls the genuine QuerySet.delete. The SoftDeleteManager is created via SoftDeleteQuerySet.as_manager() but its get_queryset() is filtered to deleted_at__isnull=True, so the default manager only ever sees live rows. A second AllObjectsManager returns everything, which is essential because an unfiltered manager is needed for the admin, for auditing, and to avoid subtle bugs when unrelated code assumes objects is complete.

The abstract SoftDeleteModel wires this together: objects hides deleted rows while all_objects exposes them. Its instance-level delete() sets deleted_at and saves rather than deleting, and restore() reverses it. Ordering the managers matters — the first declared manager becomes _default_manager, which Django uses for related lookups and some internal operations, so keeping the filtered one first means reverse relations also hide dead rows.

In models.py, Document simply inherits the base and gains soft-delete for free, including a unique_together that only makes sense against live rows. views.py shows the payoff: Document.objects.all() naturally excludes deleted records in the list view, while a restore endpoint reaches through all_objects to find the tombstoned row and revive it.

The main trade-off is that unique constraints and cascades no longer behave automatically — uniqueness must account for the tombstone, and cascading a parent's soft-delete to children requires explicit handling. This pattern fits domains where deletion is really an audit event, not a purge.


Related snips

Share this code

Here's the card — post it anywhere.

Soft-Delete in Django with a Custom Manager and QuerySet — share card
Link copied