django-soft-delete (1.0.22)
Installation
pip install --index-url django-soft-deleteAbout this package
Soft delete models, managers, queryset for Django
Django Soft Delete
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.
How to use
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
Relations
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.
Quick Start Guide
Let's walk through setting up a new project and using the core features of django-soft-delete.
1. Project Setup
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
2. Define Your Model
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
3. Configure Your Project
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',
]
4. Run Migrations
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
5. Basic Usage in the Shell
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
Working with Relationships
django-soft-delete intelligently handles relationships between models.
Cascading Soft Deletes
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
Protecting Relationships
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.
Batch Operations
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
Advanced Usage: Custom Managers and QuerySets
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.
1. Define a Custom QuerySet
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')
2. Define a Custom Manager
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()
3. Update the Model
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.
4. Test the Custom Manager
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.
Development
This project uses Hatch for environment management, testing, and building. Hatch provides a more comprehensive alternative to traditional requirements.txt files and virtualenv management.
Installing Hatch
pip install hatch
Creating Development Environments
# Create the default development environment
hatch env create
# Create the test environment with all test dependencies
hatch env create test
Setting Up the Database
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.
Activating Environments
# Activate the default environment
hatch shell
# Activate the test environment
hatch shell test
Running Tests
# Run tests with all configured options (coverage, etc.)
hatch run test:pytest
Important: Setting Up the Test Database
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.
Support me with a cup of coffee
USDT (ERC20): 0x308dad9B7014AdeD217e817B6274EeeD971200F9