What soft delete actually means
A normal Django delete removes the row from the database. If I have a project and call:
project.delete()
Django deletes that object, and depending on the relationships, it may also delete related objects through on_delete=models.CASCADE.
With soft delete, I keep the row and mark it as deleted instead. The most common way is a nullable timestamp:
deleted_at = models.DateTimeField(
null=True,
blank=True,
)
An active row has deleted_at=None. A deleted row has the timestamp for when it was removed.
I prefer a timestamp over a boolean such as is_deleted because it gives me useful information almost for free. If somebody asks when a record was removed, the answer is already in the database.
The first version might look like this:
from django.db import models
from django.utils import timezone
class Project(models.Model):
name = models.CharField(max_length=200)
deleted_at = models.DateTimeField(
null=True,
blank=True,
)
def delete(self, *args, **kwargs):
self.deleted_at = timezone.now()
self.save(
update_fields=["deleted_at"],
)
That technically works:
project.delete()
The row stays in the database and gets a timestamp.
But we're nowhere near finished.
Deleted rows still show up everywhere
If I run:
Project.objects.all()
Django still returns the deleted projects because nothing in the manager knows that deleted_at has a special meaning.
I don't want to remember this on every query:
Project.objects.filter(
deleted_at__isnull=True,
)
That gets old quickly, and eventually somebody forgets it.
A custom queryset gives us a better place to describe the behavior:
from django.db import models
class SoftDeleteQuerySet(models.QuerySet):
def active(self):
return self.filter(
deleted_at__isnull=True,
)
def deleted(self):
return self.filter(
deleted_at__isnull=False,
)
Then create a manager that returns active rows by default:
class SoftDeleteManager(models.Manager):
def get_queryset(self):
return (
SoftDeleteQuerySet(
self.model,
using=self._db,
)
.filter(
deleted_at__isnull=True,
)
)
And add it to the model:
class Project(models.Model):
name = models.CharField(max_length=200)
deleted_at = models.DateTimeField(
null=True,
blank=True,
)
objects = SoftDeleteManager()
all_objects = models.Manager()
Now:
Project.objects.all()
Only returns active projects.
If I need deleted rows for an admin page, a restore command or debugging, I can use:
Project.all_objects.all()
I like having both managers because soft delete becomes the normal behavior without making deleted data impossible to reach.
Be careful which manager Django treats as the default
Django gives the first manager on a model a special role. Several parts of Django use the model's default manager, so I don't want manager ordering to be accidental.
I would make it explicit:
class Meta:
default_manager_name = "objects"
base_manager_name = "all_objects"
The default manager hides deleted rows from normal application queries. The base manager remains unfiltered.
That distinction matters for relationships. Django needs to be able to retrieve an object that another row points to, even if the object would normally be hidden by the default manager.
I would not use a filtered manager as the base manager. Doing that can cause related-object access to return incorrect results.
Put the behavior in an abstract model
If more than one model needs soft delete, I don't want to copy the same field and methods everywhere.
An abstract base model works well:
from django.db import models
from django.utils import timezone
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
)
objects = SoftDeleteManager()
all_objects = models.Manager()
class Meta:
abstract = True
default_manager_name = "objects"
base_manager_name = "all_objects"
def delete(self, *args, **kwargs):
self.deleted_at = timezone.now()
self.save(
update_fields=["deleted_at"],
)
def restore(self):
self.deleted_at = None
self.save(
update_fields=["deleted_at"],
)
Then a normal model becomes:
class Project(SoftDeleteModel):
name = models.CharField(
max_length=200,
)
And we get:
project.delete()
project.restore()
This is about as much behavior as I want on the base model itself. I don't want a soft-delete abstraction quietly deciding how every related object in the application should behave. We'll come back to that.
QuerySet.delete() is the first trap
Overriding Model.delete() does not solve bulk deletes.
This:
Project.objects.filter(
status="archived",
).delete()
uses QuerySet.delete(). Django does not call our custom delete() method once for every model instance.
If we leave things as they are, that queryset can still physically remove rows.
I would make bulk delete explicit in the custom queryset:
from django.utils import timezone
class SoftDeleteQuerySet(models.QuerySet):
def active(self):
return self.filter(
deleted_at__isnull=True,
)
def deleted(self):
return self.filter(
deleted_at__isnull=False,
)
def delete(self):
return self.update(
deleted_at=timezone.now(),
)
def hard_delete(self):
return super().delete()
def restore(self):
return self.update(
deleted_at=None,
)
Now:
Project.objects.filter(
status="archived",
).delete()
Updates deleted_at instead of removing the rows.
If I deliberately want to destroy data, I have to say so:
Project.all_objects.filter(
deleted_at__lt=cutoff,
).hard_delete()
I prefer that naming because a destructive operation should look destructive when I read the code.
Make the manager expose the queryset methods
We can tidy the manager implementation by building it from our queryset:
class SoftDeleteManager(
models.Manager.from_queryset(
SoftDeleteQuerySet
)
):
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
deleted_at__isnull=True,
)
)
For the unfiltered manager, I like using the same queryset type:
class AllObjectsManager(
models.Manager.from_queryset(
SoftDeleteQuerySet
)
):
pass
Then the base model becomes:
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
)
objects = SoftDeleteManager()
all_objects = AllObjectsManager()
class Meta:
abstract = True
default_manager_name = "objects"
base_manager_name = "all_objects"
def delete(self, *args, **kwargs):
self.deleted_at = timezone.now()
self.save(
update_fields=["deleted_at"],
)
def hard_delete(self, *args, **kwargs):
return super().delete(
*args,
**kwargs,
)
def restore(self):
self.deleted_at = None
self.save(
update_fields=["deleted_at"],
)
This gives me a clear split between normal application queries and operations that intentionally include deleted rows.
Restoring records is easy until unique fields are involved
The basic restore method is simple:
project.restore()
Set deleted_at back to None and the row becomes active again.
The problem appears when the model contains something unique.
Imagine this:
class Project(SoftDeleteModel):
name = models.CharField(
max_length=200,
)
slug = models.SlugField(
unique=True,
)
I create:
slug="django-course"
Then I soft delete it.
From the application's point of view, that project is gone. But the database row still exists, so I cannot create another project with the same slug.
The unique=True constraint doesn't care about our manager. It sees both rows in the database.
Make uniqueness apply only to active rows
If I want deleted rows to release their slug, I can remove unique=True and use a conditional unique constraint instead:
from django.db import models
from django.db.models import Q
class Project(SoftDeleteModel):
name = models.CharField(
max_length=200,
)
slug = models.SlugField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["slug"],
condition=Q(
deleted_at__isnull=True,
),
name=(
"unique_active_project_slug"
),
),
]
Now two deleted projects can have the same slug, while only one active project can use it.
This is usually much closer to what I want from soft delete.
There is a database detail here though. Conditional unique constraints rely on partial unique indexes. PostgreSQL supports them, and SQLite supports partial indexes with some restrictions. MySQL and MariaDB don't support conditional indexes, so Django ignores the condition there.
If I'm using soft delete heavily, this is one of those cases where the database choice affects the design rather than being an invisible implementation detail.
Restoring can now fail
The conditional constraint fixes creation, but it introduces another case we need to handle.
Imagine I create a project with:
slug="django-course"
Then soft delete it.
Later, somebody creates a new active project using the same slug. That's allowed because the old row is deleted.
What happens if I now restore the old project?
The database correctly refuses it because we'd end up with two active rows using the same unique value.
I wouldn't try to hide that. Restoring is a real operation and it can have conflicts.
A restore service can check for them first:
from django.core.exceptions import (
ValidationError,
)
def restore_project(project):
slug_exists = (
Project.objects
.filter(
slug=project.slug,
)
.exclude(
pk=project.pk,
)
.exists()
)
if slug_exists:
raise ValidationError(
{
"slug": (
"An active project already "
"uses this slug."
),
}
)
project.restore()
The database constraint still matters because application-level checks can race. The validation is mostly there so I can give the user a useful error instead of exposing an integrity error.
Related objects are where soft delete gets complicated
Suppose projects have tasks:
class Task(SoftDeleteModel):
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="tasks",
)
title = models.CharField(
max_length=200,
)
With a normal hard delete, CASCADE has a clear meaning. Delete the project and Django deletes its tasks.
But when I soft delete the project, no database deletion happens. Django's cascade logic is never triggered.
The tasks remain active.
That isn't necessarily wrong. It depends on what soft delete is supposed to mean in the application.
This is why I don't like making a generic base model automatically walk through every relationship and soft delete everything it finds. Different relationships often need different rules.
Option 1: Leave child rows alone
One approach is to soft delete only the project:
project.delete()
The tasks stay unchanged.
This can be perfectly reasonable if tasks should come back automatically when the project is restored. The project is hidden, so normal application pages never expose those tasks anyway.
If I later run:
project.restore()
The tasks are still there exactly as they were before.
I like this approach when the child objects don't have an independent lifecycle. A task belongs to the project, and hiding the project effectively hides the task.
The downside is that global queries need to respect the parent state. This:
Task.objects.all()
still returns tasks belonging to deleted projects unless the task itself was soft deleted.
If that's a problem, I need to filter on the relationship:
Task.objects.filter(
project__deleted_at__isnull=True,
)
Option 2: Soft delete children explicitly
Sometimes I do want deleting a parent to mark the children as deleted too.
I would make that behavior explicit in a service:
from django.db import transaction
from django.utils import timezone
@transaction.atomic
def delete_project(project):
deleted_at = timezone.now()
Task.all_objects.filter(
project=project,
deleted_at__isnull=True,
).update(
deleted_at=deleted_at,
)
Project.all_objects.filter(
pk=project.pk,
).update(
deleted_at=deleted_at,
)
Now the project and its tasks share the same deletion timestamp.
That gives me a useful way to know which child rows were removed as part of that operation.
It also avoids relying on on_delete=models.CASCADE for behavior it was never designed to handle. on_delete runs when the referenced row is actually deleted, not when I update a timestamp.
Restoring children needs a policy too
If I soft delete all tasks when the project is deleted, should restoring the project restore every task?
Maybe.
But imagine one task had already been deleted manually three weeks before the project itself was deleted. I probably don't want restoring the project to bring that old task back too.
This is another reason a single deleted_at field can become insufficient if I want automatic cascading and restoring.
One option is to store a deletion batch identifier:
deletion_id = models.UUIDField(
null=True,
blank=True,
)
When I delete the project, I create one UUID and assign it to the project and all children deleted as part of the same operation.
Then restore can target only that batch.
For many applications, that's more complexity than soft delete is worth. I would only add it if automatic cascading and restoration are actual product requirements.
PROTECT and RESTRICT don't work the same way either
Normal Django relationships can use:
on_delete=models.PROTECT
Or:
on_delete=models.RESTRICT
Those rules apply to real deletes. If my soft delete is just an UPDATE, neither rule prevents it.
If the business says "a customer cannot be removed while they still have unpaid invoices", that check belongs in my soft-delete workflow:
from django.core.exceptions import (
ValidationError,
)
def delete_customer(customer):
has_unpaid_invoices = (
customer.invoices.filter(
status="unpaid",
).exists()
)
if has_unpaid_invoices:
raise ValidationError(
"Customers with unpaid invoices "
"cannot be deleted."
)
customer.delete()
I wouldn't assume the foreign key will protect me here, because from the database's point of view we're not deleting the row at all.
Think carefully before hiding deleted rows in every relationship
Let's say I have an invoice that points to a customer:
class Invoice(models.Model):
customer = models.ForeignKey(
Customer,
on_delete=models.PROTECT,
)
The customer gets soft deleted.
I still want this to work:
invoice.customer
The invoice is historical data. It should still know which customer it belonged to.
This is why the unfiltered base manager matters. Django can still retrieve a related customer even though normal Customer.objects queries hide it.
I don't want soft deletion to make existing relationships look broken.
Reverse relationships can surprise you
Soft-delete managers also deserve testing around reverse relationships.
Suppose a customer has notes:
class Note(SoftDeleteModel):
customer = models.ForeignKey(
Customer,
on_delete=models.CASCADE,
related_name="notes",
)
If the Note default manager filters deleted rows, then normal related queries should follow that manager behavior:
customer.notes.all()
I would add tests for this rather than trusting assumptions about managers and relationships. Soft delete is exactly the type of feature where the obvious query works and an obscure admin page six months later exposes something you thought was hidden.
Admin pages need access to deleted rows
Django admin is one place where I often want the opposite behavior from the public application.
Normal pages should hide deleted projects. The admin might need to show them so somebody can restore a record.
I can override the queryset:
from django.contrib import admin
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
def get_queryset(self, request):
return Project.all_objects.all()
Then add an admin action for restoring:
from django.contrib import admin
@admin.action(
description="Restore selected projects"
)
def restore_projects(
modeladmin,
request,
queryset,
):
queryset.update(
deleted_at=None,
)
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
actions = [
restore_projects,
]
def get_queryset(self, request):
return Project.all_objects.all()
For models with unique constraints or more complicated restore logic, I would not use a raw queryset update like this. I'd call the real restore service so conflicts are handled correctly.
Eventually I still want to delete old rows
Soft delete doesn't necessarily mean storing everything forever.
I might want users to have 30 days to restore a project, then permanently remove it later.
A management command works well for that:
from datetime import timedelta
from django.core.management.base import (
BaseCommand,
)
from django.utils import timezone
from projects.models import Project
class Command(BaseCommand):
def handle(self, *args, **options):
cutoff = (
timezone.now()
- timedelta(days=30)
)
deleted_projects = (
Project.all_objects
.filter(
deleted_at__lt=cutoff,
)
)
deleted_projects.hard_delete()
This is where Django's normal cascade rules start applying again. A hard delete is a real delete, so foreign-key behavior such as CASCADE, PROTECT and RESTRICT matters again.
I would test this cleanup process carefully. The fact that a record has been soft deleted for 30 days doesn't automatically mean every related object can safely disappear.
Don't use soft delete for everything
I don't automatically add soft delete to every model.
Some data has no useful restore story. Session-like records, temporary imports, cached data and generated rows can often be deleted normally.
Soft delete adds permanent complexity to querying, uniqueness, relationships, admin tools and cleanup. I want that complexity to solve a real problem.
For user-created content, projects, customers, documents or anything where accidental deletion would be painful, it can be worth it. For a temporary API log, probably not.
My complete base implementation
For a reasonably simple project, this is close to the version I would start with:
from django.db import models
from django.utils import timezone
class SoftDeleteQuerySet(models.QuerySet):
def active(self):
return self.filter(
deleted_at__isnull=True,
)
def deleted(self):
return self.filter(
deleted_at__isnull=False,
)
def delete(self):
return self.update(
deleted_at=timezone.now(),
)
def hard_delete(self):
return super().delete()
def restore(self):
return self.update(
deleted_at=None,
)
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
)
):
pass
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(
null=True,
blank=True,
db_index=True,
)
objects = SoftDeleteManager()
all_objects = AllObjectsManager()
class Meta:
abstract = True
default_manager_name = "objects"
base_manager_name = "all_objects"
def delete(self, *args, **kwargs):
self.deleted_at = timezone.now()
self.save(
update_fields=["deleted_at"],
)
def restore(self):
self.deleted_at = None
self.save(
update_fields=["deleted_at"],
)
def hard_delete(
self,
*args,
**kwargs,
):
return super().delete(
*args,
**kwargs,
)
This solves the parts I want to solve globally: normal queries hide deleted rows, deleted rows remain accessible, queryset deletes are soft by default, and hard deletion is still available when I explicitly ask for it.
I would keep relationship rules outside this base class. Whether deleting a project should also delete its tasks is an application decision, not something a generic SoftDeleteModel can guess correctly.
The tests I would write before trusting it
Soft delete is easy to demonstrate and surprisingly easy to get wrong, so I would write tests around the behavior rather than only testing that deleted_at gets populated.
from django.test import TestCase
from projects.models import Project
class SoftDeleteTests(TestCase):
def test_deleted_project_is_hidden(self):
project = Project.objects.create(
name="Test project",
)
project.delete()
self.assertFalse(
Project.objects.filter(
pk=project.pk,
).exists()
)
self.assertTrue(
Project.all_objects.filter(
pk=project.pk,
).exists()
)
def test_project_can_be_restored(self):
project = Project.objects.create(
name="Test project",
)
project.delete()
project.restore()
self.assertTrue(
Project.objects.filter(
pk=project.pk,
).exists()
)
def test_queryset_delete_is_soft(self):
project = Project.objects.create(
name="Test project",
)
Project.objects.filter(
pk=project.pk,
).delete()
self.assertTrue(
Project.all_objects.filter(
pk=project.pk,
).exists()
)
I would add separate tests for unique constraints and every relationship where deletion behavior matters. Those are the areas most likely to surprise me later.
Soft delete should be boring to use
The implementation has quite a few details, but the code using it should stay simple.
Normal application code should be able to do:
Project.objects.all()
project.delete()
project.restore()
And only the parts of the application that deliberately deal with deleted data should need:
Project.all_objects.all()
That's the balance I'm looking for. I want deleted rows hidden by default, but I don't want them hidden so aggressively that Django's relationships stop making sense or restoring data becomes a collection of special cases.
The biggest mistake, in my opinion, is treating soft delete as nothing more than adding an is_deleted field. The field is the easy part. Managers, bulk deletes, unique constraints and related objects are what decide whether the implementation still feels clean a year later.