This is a set of small classes to make soft deletion of objects.
Use the abstract model SoftDeleteModel
for adding two new fields:
is_deleted
- is a boolean field, shows weather of a deletion state of objectdeleted_at
- is a DateTimeField, serves a timestamp of deletion.
Also, you can use SoftDeleteManager
and DeletedManager
object managers for getting
alive and deleted objects accordingly.
By default, the SoftDeleteModel
has objects
attribute as SoftDeleteManager
and
deleted_objects
attribute as DeletedManager
.
pip install django-soft-delete
Add the SoftDeleteModel
as a parent for your model:
# For regular model
from django.db import models
from django_softdelete.models import SoftDeleteModel
class Article(SoftDeleteModel):
title = models.CharField(max_length=100)
# Following fields will be added automatically
# is_deleted
# deleted_at
# Following managers will be added automatically
# objects = SoftDeleteManager()
# deleted_objects = DeletedManager()
# global_objects = GlobalManager()
# For inherited model
from django_softdelete.models import SoftDeleteModel
class Post(SoftDeleteModel, SomeParentModelClass):
title = models.CharField(max_length=100)
Make and apply the migrations:
./manage.py makemigrations
./manage.py migrate
You can also use soft deletion for models that are related to others. This library does not interfere with standard Django functionality. This means that you can also use cascading delete, but remember that soft delete only works with the SoftDeleteModel
classes. If you delete an instance of the parent model and use cascading delete, then the instances of the child model will be hard-deleted. To prevent hard deletion in this case, you should use SoftDeleteModel
for child models in the same way as for parent model.
Let's walk through setting up a new project and using the core features of django-soft-delete
.
First, set up your Django project. We'll use uv
here, but you can use venv
and pip
as well.
# Initialize a virtual environment
uv init
# Add django and django-soft-delete
uv add django django-soft-delete
# Create a new Django project and an app
uv run django-admin startproject myproject
cd myproject
uv run python manage.py startapp core
Now, edit core/models.py
and make your model inherit from SoftDeleteModel
.
from django.db import models
from django_softdelete.models import SoftDeleteModel
class Article(SoftDeleteModel):
title = models.CharField(max_length=100)
# The following are automatically added by SoftDeleteModel:
#
# FIELDS
# is_deleted: BooleanField
# deleted_at: DateTimeField
#
# MANAGERS
# objects: SoftDeleteManager (default, sees only "alive" objects)
# deleted_objects: DeletedManager (sees only "deleted" objects)
# global_objects: GlobalManager (sees all objects, alive or deleted)
def __str__(self):
return self.title
Add your new core
app to the INSTALLED_APPS
list in myproject/settings.py
.
# myproject/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Add your app here
'core',
]
Create and apply the database migrations. SoftDeleteModel
will add the is_deleted
and deleted_at
fields to your Article
table.
uv run manage.py makemigrations core
uv run manage.py migrate
Let's see it in action! Start the Django shell:
uv run manage.py shell
Now, run these instructions one by one to see how soft deletion works.
from core.models import Article
# Create a few articles
a1 = Article.objects.create(title='Intro to Django Soft Delete')
a2 = Article.objects.create(title='Advanced Python')
a3 = Article.objects.create(title='Using django-soft-delete')
# The default manager only sees "alive" objects
print(f"Initial count: {Article.objects.count()}")
# >>> Initial count: 3
# --- Soft-delete an article ---
# This doesn't remove it from the DB, just marks it as deleted
a1.delete()
# The default manager now shows one less object
print(f"Count after soft delete: {Article.objects.count()}")
# >>> Count after soft delete: 2
print(Article.objects.all())
# >>> <QuerySet [<Article: Advanced Python>, <Article: Using django-soft-delete>]>
# --- Finding and Restoring Deleted Objects ---
# Use `deleted_objects` to find deleted items
print(f"Deleted objects count: {Article.deleted_objects.count()}")
# >>> Deleted objects count: 1
deleted_a1 = Article.deleted_objects.first()
print(f"Found deleted article: {deleted_a1}")
# >>> Found deleted article: Intro to Django Soft Delete
# Restore the deleted article
deleted_a1.restore()
print(f"Count after restore: {Article.objects.count()}")
# >>> Count after restore: 3
print(f"Deleted count after restore: {Article.deleted_objects.count()}")
# >>> Deleted count after restore: 0
# --- Permanent Deletion ---
# To permanently delete an object, use `hard_delete()`
a3.hard_delete()
print(f"Count after hard delete: {Article.objects.count()}")
# >>> Count after hard delete: 2
# --- Viewing All Objects ---
# You can query all objects, alive or deleted, with `global_objects`
print(f"Global count (alive + deleted): {Article.global_objects.count()}")
# >>> Global count (alive + deleted): 2
django-soft-delete
intelligently handles relationships between models.
If a related model also inherits from SoftDeleteModel
, deleting the parent will soft-delete the children.
Let's add these models to core/models.py
and run migrations again.
class Product(SoftDeleteModel):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
class Option(SoftDeleteModel): # Also a SoftDeleteModel
# Using models.CASCADE will trigger a soft-delete on the Option
product = models.ForeignKey(Product, on_delete=models.CASCADE)
name = models.CharField(max_length=32)
def __str__(self):
return f"{self.product.name} - {self.name}"
Now, test the cascading behavior in the shell:
from core.models import Product, Option
p = Product.objects.create(name='Laptop')
Option.objects.create(product=p, name='16GB RAM')
print(f"Initial option count: {Option.objects.count()}")
# >>> Initial option count: 1
# Soft-deleting the Product...
p.delete()
# ...also soft-deletes the related Option because it's a SoftDeleteModel
print(f"Option count after parent delete: {Option.objects.count()}")
# >>> Option count after parent delete: 0
print(f"Deleted option count: {Option.deleted_objects.count()}")
# >>> Deleted option count: 1
You can use Django's standard on_delete=models.PROTECT
to prevent a model from being deleted if it has active children, and this works perfectly with soft deletion.
You can perform soft-delete and restore operations on entire querysets, making bulk updates easy.
# Create some data
Article.objects.create(title='Django Best Practices')
Article.objects.create(title='Advanced Django')
# Soft-delete all articles with 'Django' in the title
Article.objects.filter(title__icontains='Django').delete()
print(f"Alive articles count: {Article.objects.count()}")
# >>> Alive articles count: 1 (Only 'Advanced Python' remains)
# Restore all deleted articles
Article.deleted_objects.all().restore()
print(f"Alive articles count after restore: {Article.objects.count()}")
# >>> Alive articles count after restore: 4
A common requirement is to add custom methods to your model's manager (e.g., Sale.objects.by_product(...)
). To do this without losing the soft-delete logic, you must inherit from SoftDeleteQuerySet
and SoftDeleteManager
.
Here is a complete example.
Create a new file core/querysets.py
. Your custom QuerySet
will contain the filtering logic.
# Note that django_softdelete.querysets doesn't contain SoftDeleteQuerySet class so you must import it from django_softdelete.managers
from django_softdelete.managers import SoftDeleteQuerySet
class SaleQuerySet(SoftDeleteQuerySet):
def by_product(self, product_id):
return self.filter(product_id=product_id)
def by_price(self, price):
return self.filter(sale_price__gt=price)
def with_related_data(self):
return self.select_related('product')
Create a new file core/managers.py
. The manager will use your custom QuerySet
.
from django_softdelete.managers import SoftDeleteManager
from .querysets import SaleQuerySet
class SaleManager(SoftDeleteManager):
def get_queryset(self):
# Ensure the base queryset is used so core soft-delete features work
base_qs = super().get_queryset()
# Use our custom queryset for all manager calls
return SaleQuerySet(model=self.model, query=base_qs.query, using=self._db)
def by_product(self, product_id):
"""A manager method that uses a custom queryset method."""
return self.get_queryset().by_product(product_id).with_related_data()
def by_price(self, price):
"""Another manager method that uses a custom queryset method."""
return self.get_queryset().by_price(price).with_related_data()
Finally, update core/models.py
to use the new manager.
# ... (imports and other models) ...
from .managers import SaleManager
class Sale(SoftDeleteModel):
product = models.ForeignKey(Product, on_delete=models.PROTECT)
name = models.CharField(max_length=32)
sale_price = models.DecimalField(max_digits=5, decimal_places=2)
# Assign your custom manager
objects = SaleManager()
def __str__(self):
return f"{self.name} - {self.product.name} (${self.sale_price})"
After adding these files and updating the model, run makemigrations
and migrate
.
This script instructions demonstrates how could custom methods work perfectly alongside the soft-delete functionality and Django's on_delete=PROTECT
.
# --- SETUP ---
from django.db.models import ProtectedError
from core.models import Product, Sale
# --- 1. CREATE SOME DATA ---
product_a = Product.objects.create(name="Laptop Pro")
product_b = Product.objects.create(name="Wireless Mouse")
Sale.objects.create(name="Summer Sale", product=product_a, sale_price=99.99)
Sale.objects.create(name="Clearance", product=product_a, sale_price=75.50)
Sale.objects.create(name="Flash Sale", product=product_b, sale_price=25.00)
Sale.objects.create(name="Black Friday", product=product_b, sale_price=19.99)
print(f"Total sales created: {Sale.objects.count()}")
# Total sales created: 4
# --- 2. TEST THE CUSTOM MANAGER METHODS ---
print("\n--- Testing by_product() ---")
laptop_sales = Sale.objects.by_product(product_id=product_a.id)
print(f"Found {laptop_sales.count()} sales for {product_a.name}:")
for sale in laptop_sales:
print(f"- {sale}")
# Found 2 sales for Laptop Pro:
# - Summer Sale - Laptop Pro ($99.99)
# - Clearance - Laptop Pro ($75.50)
print("\n--- Testing by_price() ---")
expensive_sales = Sale.objects.by_price(price=50.00)
print(f"Found {expensive_sales.count()} sales with price > $50.00:")
for sale in expensive_sales:
print(f"- {sale}")
# Found 2 sales with price > $50.00:
# - Summer Sale - Laptop Pro ($99.99)
# - Clearance - Laptop Pro ($75.50)
# --- 3. VERIFY SOFT-DELETE STILL WORKS ---
print("\n--- Testing Soft Delete ---")
sale_to_delete = Sale.objects.get(name="Flash Sale")
print(f"Soft-deleting '{sale_to_delete.name}'...")
sale_to_delete.delete()
print(f"Total alive sales now: {Sale.objects.count()}")
# Total alive sales now: 3
# Check that our custom filter still works and respects the soft delete
mouse_sales = Sale.objects.by_product(product_id=product_b.id)
print(f"Found {mouse_sales.count()} alive sales for {product_b.name}:")
for sale in mouse_sales:
print(f"- {sale}")
# Found 1 alive sales for Wireless Mouse:
# - Black Friday - Wireless Mouse ($19.99)
# Find and restore the deleted sale
deleted_sale = Sale.deleted_objects.get(name="Flash Sale")
print(f"Restoring '{deleted_sale.name}'...")
deleted_sale.restore()
print(f"Total alive sales after restore: {Sale.objects.count()}")
# Total alive sales after restore: 4
# --- 4. TEST 'on_delete=PROTECT' ---
print("\n--- Testing on_delete=PROTECT ---")
print(f"Attempting to delete product '{product_a.name}', which has active sales...")
try:
product_a.delete()
except ProtectedError as e:
print("SUCCESS: Django raised ProtectedError, as expected!")
# The exact error message may vary slightly between Django versions
print("Error message contains protected foreign key references.")
# SUCCESS: Django raised ProtectedError, as expected!
# Error message contains protected foreign key references.
This project uses Hatch for environment management, testing, and building. Hatch provides a more comprehensive alternative to traditional requirements.txt files and virtualenv management.
pip install hatch
# Create the default development environment
hatch env create
# Create the test environment with all test dependencies
hatch env create test
The test project uses SQLite by default. You don't need to manually create the database file as Django will create it automatically when needed.
# Activate the test environment
hatch shell test
# Create and apply migrations
python manage.py makemigrations
python manage.py migrate
This will create the SQLite database file at the location specified in settings.py (./db.sqlite3
) and set up all necessary tables.
# Activate the default environment
hatch shell
# Activate the test environment
hatch shell test
# Run tests with all configured options (coverage, etc.)
hatch run test:pytest
Before running tests, you must create and apply migrations for the test app:
# Activate the test environment
hatch shell test
# Create migrations for the test app models (required before first test run)
python manage.py makemigrations test_app
# Apply all migrations
python manage.py migrate
If you encounter "no such table" errors during testing, this usually means you need to run the migration steps above.
USDT (ERC20): 0x308dad9B7014AdeD217e817B6274EeeD971200F9