In data-driven business applications, deleting records from the database is rarely a good idea. Accidental clicks can lead to catastrophic data loss, and hard-deleting records breaks historical audits and referential integrity. The industry standard is Soft Deletion—marking records as "deleted" via a timestamp column instead of actually removing the rows.

While there are several third-party Django libraries (like django-safedelete) that handle soft deletion, they often come with heavy overhead, buggy database migrations, and complex behaviors when performing cascading deletes or object serialization. In this post, we'll explore how to build a lightweight, bulletproof Soft Delete system from scratch using custom Django Managers and QuerySets.


The System Architecture: Three Simple Components

To build a native soft-delete system in Django, we need to customize three primary layers:

  1. Custom QuerySet: Ensures that bulk operations (like .delete() and .restore()) update the timestamp column instead of dropping the rows.
  2. Custom Managers: Defines how models query records by default (filtering out deleted items) while providing raw entry points for trash administration.
  3. Abstract Model: Serves as the base model containing the deleted_at timestamp and overriding the default delete() method.

  4. Step 1: Custom QuerySets and Managers

    We'll create a SoftDeleteQuerySet that overrides the bulk .delete() call to execute an .update() statement. We'll also define a DeletedObjectQuerySet with a custom .restore() utility:

    from django.db import models
    from django.utils.timezone import now
    
    class SoftDeleteQuerySet(models.QuerySet):
        def delete(self):
            # Override bulk delete to set timestamp
            return self.update(deleted_at=now())
    
    class DeletedObjectQuerySet(models.QuerySet):
        def restore(self):
            # Bulk restore
            return self.update(deleted_at=None)
    
    class SoftDeleteManager(models.Manager):
        def get_queryset(self):
            # Default manager only queries active records
            return SoftDeleteQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True)
    
    class DeletedObjectManager(models.Manager):
        def get_queryset(self):
            # Queries ONLY deleted items (the trash bin)
            return DeletedObjectQuerySet(self.model, using=self._db).filter(deleted_at__isnull=False)
    

    Step 2: Building the Abstract Base Model

    Next, we construct the abstract model. We define three managers: the default objects (active items only), deleted_objects (deleted items), and all_objects (a raw, unfiltered manager representing the entire physical table):

    class SoftDeleteModel(models.Model):
        deleted_at = models.DateTimeField(blank=True, null=True, default=None)
    
        # Register custom managers
        objects = SoftDeleteManager()
        deleted_objects = DeletedObjectManager()
        all_objects = models.Manager()  # Native raw manager
    
        class Meta:
            abstract = True
    
        def delete(self, *args, **kwargs):
            # Single instance soft delete
            self.deleted_at = now()
            self.save(update_fields=['deleted_at'])
    
        def restore(self):
            # Restore instance
            self.deleted_at = None
            self.save(update_fields=['deleted_at'])
    
        @property
        def is_deleted(self):
            return self.deleted_at is not None
    

    Step 3: Integrating Soft Deletes in Your Code

    Using the soft-delete model is incredibly simple. Just inherit from SoftDeleteModel when writing your tables, and Django handles the filtering transparently:

    class Product(SoftDeleteModel):
        name = models.CharField(max_length=100)
        price = models.DecimalField(max_digits=10, decimal_places=2)
    
    # Queries:
    # 1. Fetching active products (automatically excludes deleted ones)
    active_products = Product.objects.all()
    
    # 2. Soft-deleting a product
    product = Product.objects.first()
    product.delete()  # Simply sets product.deleted_at = now()
    
    # 3. Viewing the trash bin
    trash_bin = Product.deleted_objects.all()
    
    # 4. Restoring a product
    deleted_product = Product.deleted_objects.first()
    deleted_product.restore()  # Sets deleted_at = None
    

    Key Implementation Advantages

    1. ORM Compatibility: Overriding bulk .delete() inside the QuerySet guarantees that bulk executions (e.g. Product.objects.filter(price__gt=100).delete()) perform soft deletions correctly.
    2. Referential Integrity: Using an abstract class allows you to maintain foreign keys, avoiding complex database cascade configurations.
    3. Lightweight Footprint: Zero external third-party dependencies! This guarantees smooth database migrations and full compatibility with future Django versions.

    Building your own soft-delete framework in Django requires less than 50 lines of clean Python code, providing a secure, performant, and maintainable trash bin system for your enterprise datasets.