Django’s SoftDelete isn’t a built-in feature, but implementing it is surprisingly straightforward and powerful. The most impactful thing to realize is that soft deletion doesn’t actually remove data; it just hides it, which is a godsend for auditing and accidental data loss.

Let’s see it in action. Imagine a Product model.

from django.db import models
from django.utils import timezone
from django.db.models import Q

class SoftDeleteManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(deleted_at=None)

    def all_with_deleted(self):
        return super().get_queryset()

    def get(self, *args, **kwargs):
        # Ensure we don't accidentally fetch a deleted object if deleted_at is not explicitly excluded
        kwargs['deleted_at'] = None
        return super().get(*args, **kwargs)

class SoftDeleteModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    deleted_at = models.DateTimeField(blank=True, null=True)

    objects = SoftDeleteManager()

    class Meta:
        abstract = True

    def delete(self, *args, **kwargs):
        if self.deleted_at is None:
            self.deleted_at = timezone.now()
            self.save()
        else:
            # If already deleted, perform a hard delete
            super().delete(*args, **kwargs)

    def restore(self):
        if self.deleted_at is not None:
            self.deleted_at = None
            self.save()

    def is_deleted(self):
        return self.deleted_at is not None

    def save(self, *args, **kwargs):
        # Ensure deleted_at is not set during creation if it's None
        if self.pk is None and self.deleted_at is None:
            self.deleted_at = None
        super().save(*args, **kwargs)


class Product(SoftDeleteModel):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)

    def __str__(self):
        return self.name

Now, when you interact with Product objects using the default objects manager, deleted items are invisible.

# Create a product
product1 = Product.objects.create(name="Laptop", price=1200.00)
print(Product.objects.count()) # Output: 1

# Soft delete it
product1.delete()
print(Product.objects.count()) # Output: 0

# To see deleted items, use the custom manager method
print(Product.objects.all_with_deleted().count()) # Output: 1
print(Product.objects.all_with_deleted().first().name) # Output: Laptop

# Restore it
product1.restore()
print(Product.objects.count()) # Output: 1
print(Product.objects.first().name) # Output: Laptop

The core problem this solves is the irreversible nature of database DELETE statements. With soft delete, you retain historical data, enable easy recovery from accidental deletions, and can build audit trails directly into your models. The SoftDeleteManager intercepts queries, filtering out records where deleted_at is populated. The delete method on the model itself doesn’t execute a SQL DELETE; instead, it updates the deleted_at timestamp. The restore method simply sets deleted_at back to None.

Under the hood, the SoftDeleteManager overrides get_queryset to apply a filter(deleted_at=None) by default. This means any query using Product.objects will automatically exclude soft-deleted items. The all_with_deleted method is provided to bypass this filter when you explicitly need to see everything. The get method override is crucial to prevent fetching a deleted object if you try to retrieve it by its primary key without explicitly handling deleted_at.

The delete method’s logic is key: it checks if deleted_at is already set. If it is, it means the object was already soft-deleted, so it proceeds with a hard super().delete() to actually remove it from the database. This prevents an infinite loop of soft-deleting an already soft-deleted object. The save method override ensures that deleted_at isn’t accidentally set to None when a new object is created.

A subtle but important point: if you have custom managers or use select_related/prefetch_related on a model with soft delete, you need to be mindful of how deleted_at filters apply. For instance, if you prefetch_related('related_model') and related_model also uses soft delete, you might need to explicitly use all_with_deleted() on the related manager if you want to include prefetched items that are soft-deleted.

The next logical step is integrating this into your admin interface, perhaps by adding a "Show Deleted" toggle and a "Restore" action.

Want structured learning?

Take the full Django course →