x-django-paranoid
Soft-delete for Django that actually works in real projects.
You get delete() that doesn't really delete — it just hides the row — plus the fixes that production apps hit within the first month: unique fields, restoring, related objects, admin, and cleanup.
pip install x-django-paranoid
from x_paranoid.models import XParanoidModel
class Article(XParanoidModel):
slug = models.SlugField(max_length=80)
title = models.CharField(max_length=200)
That's it. Article.objects now hides deleted rows. Article.objects_with_deleted shows everything. article.delete() is reversible.
Why you need soft-delete
Vanilla Model.delete() is permanent. In real apps you almost always regret it:
- A customer deletes their account by mistake and support needs to restore it.
- An admin deletes a product that has orders attached — you lose history.
- A user creates a blog post with slug
hello, deletes it, then can't re-createhellobecause the old row still blocksunique=True. - You delete an author and all their books disappear forever, even the ones that were already deleted separately.
x-django-paranoid makes delete() reversible and makes those edge cases predictable.
When to use it
Pick x-django-paranoid if you:
- Run a SaaS / marketplace / multi-tenant app — users, shops, listings get deleted all the time. You need to let them re-create the same
slug,username, orskuafter deletion, and let support restore without creating duplicates. - Keep an audit trail — orders, invoices, comments, or logs should never be hard-deleted. Soft-delete keeps them for reports while hiding them from the app.
- Need a trash bin — admins want to see "Active / Deleted / All", restore one click, and auto-purge old trash after 30/90 days.
- Build an API with DRF —
DELETE /articles/1/should soft-delete,POST /articles/1/restore/should restore, and creating a newslugafter a delete should not fail validation. - Don't want to think about related data — deleting an author should soft-delete their books, but restoring the author should not resurrect a book that was already deleted before the author.
If you just need a simple is_active flag and will never restore, you don't need this. If you need soft-delete with any of the above, you do.
Installation
pip install x-django-paranoid
# for API features
pip install djangorestframework
# settings.py
INSTALLED_APPS = [
"x_paranoid",
# ...
]
No other setup. It works on SQLite and Postgres, Django 3.2+.
60-second quickstart
from x_paranoid.models import XParanoidModel
from x_paranoid.constraints import ParanoidUniqueConstraint
class Article(XParanoidModel):
slug = models.SlugField(max_length=80)
title = models.CharField(max_length=200)
class Meta:
constraints = [
ParanoidUniqueConstraint(fields=["slug"], name="uniq_slug_active")
]
# create
a = Article.objects.create(slug="hello", title="Hello")
# soft-delete — row stays in DB, just hidden
a.delete()
Article.objects.count() # 0
Article.objects_with_deleted.count() # 1
Article.objects_with_deleted.get(id=a.id).deleted_at # 2025-...
# restore
a = Article.objects_with_deleted.get(id=a.id)
a.restore()
Article.objects.count() # 1 again
# really delete when you mean it
a.hard_delete()
Two managers you will use every day:
Article.objects— only alive rows (what your app sees).Article.objects_with_deleted— everything, including trash. Use it in admin, scripts, and when you need to restore.
It also works in bulk:
Article.objects.filter(author=user).delete() # soft
Article.objects_with_deleted.filter(author=user).restore() # restore
Article.objects.filter(slug="old").hard_delete() # hard
Common patterns
1. Re-using a unique value after delete
Problem: slug=hello is unique=True. You delete it, then try to create a new hello → IntegrityError because the old row still exists.
Fix: Use ParanoidUniqueConstraint. It creates a partial index WHERE deleted_at IS NULL, so uniqueness only applies to alive rows.
class Article(XParanoidModel):
slug = models.SlugField()
class Meta:
constraints = [ParanoidUniqueConstraint(fields=["slug"], name="uniq_slug")]
Now you can delete hello and create a new hello. Two hello rows can exist at the same time, but only one can be alive.
For tenant-scoped uniqueness:
ParanoidUniqueConstraint(fields=["slug"], condition=Q(tenant_id=1), name="uniq_slug_tenant")
2. What happens when restore would create a duplicate?
You deleted hello, someone created a new hello, now you restore the old one — which hello wins?
Pick a policy per model:
class Article(XParanoidModel):
slug = models.SlugField()
class XParanoidMeta:
on_restore_conflict = "RAISE_ERROR" # default — raise, let you handle it
# "FORK_RENAME" — rename old to "hello-restored-42" and restore
# "OVERWRITE_ACTIVE" — delete the new one, restore the old
rename_template = "{value}-restored-{id}"
3. Deleting a parent and its children
author = Author.objects.create(name="Asimov")
book1 = Book.objects.create(title="Foundation", author=author)
book2 = Book.objects.create(title="I Robot", author=author)
book2.delete() # deleted separately, before the author
author.delete() # soft-deletes author + only book1 (same batch)
author.restore() # restores author + book1, book2 stays deleted
We stamp a deletion_batch_id (UUID) on every row deleted together. Restoring only brings back rows from that same batch. Other soft-deleted rows are left alone — this is the bug that plain django-safedelete gets wrong.
For deep trees (Author → Article → Comment) it recurses automatically — same batch flows to grandchildren.
4. Safe foreign keys
from x_paranoid.fields import XParanoidForeignKey
class Book(XParanoidModel):
author = XParanoidForeignKey(Author, on_delete=models.CASCADE, null=True)
Now book.author after the author is soft-deleted returns None instead of a ghost object. Add raise_on_deleted=True if you prefer an exception (ObjectSoftDeleted) instead of None.
5. Many-to-many that keeps history
Plain ManyToManyField hard-deletes the junction row on .remove(). To keep history:
class Category(XParanoidModel):
name = models.CharField(max_length=50)
class BookCategory(XParanoidModel):
book = models.ForeignKey(Book2, on_delete=models.CASCADE)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
class Book2(XParanoidModel):
title = models.CharField(max_length=50)
categories = models.ManyToManyField(Category, through=BookCategory)
# don't use .add/.remove
BookCategory.objects.create(book=book, category=cat)
BookCategory.objects.filter(book=book, category=cat).delete() # soft
The XParanoidManyToManyField marker will warn you (W001) if your through model doesn't inherit XParanoidModel.
Admin trash bin
# admin.py
from x_paranoid.admin import XParanoidAdmin
admin.site.register(Article, XParanoidAdmin)
You get:
- Filters: Active / Soft Deleted / All
- A Deleted badge in the list
- A bulk action Restore selected
Under the hood it uses objects_with_deleted so you actually see the trash.
API (Django REST Framework)
Serializer — so creating hello after a soft-delete doesn't fail validation:
from x_paranoid.serializers import XParanoidSerializerMixin
class ArticleSerializer(XParanoidSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Article
fields = ["id", "slug", "title"]
ViewSet — trash-bin endpoints out of the box:
from x_paranoid.drf import SoftDeleteModelMixin, RestoreModelMixin, DeletedListModelMixin
class ArticleViewSet(SoftDeleteModelMixin, RestoreModelMixin, DeletedListModelMixin, viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
DELETE /articles/1/→ soft-deleteDELETE /articles/1/?hard=true→ hard-deletePOST /articles/1/restore/→ restoreGET /articles/deleted/→ list trash
Automatic cleanup
Keep trash from growing forever:
class Article(XParanoidModel):
class XParanoidMeta:
auto_hard_delete_after = timedelta(days=90)
# dry run
python manage.py purge_soft_deleted --dry-run
# actually hard-delete rows soft-deleted >90 days ago (or 30 days if you pass --days)
python manage.py purge_soft_deleted
python manage.py purge_soft_deleted --days 30
# add to cron / celery beat
Migrating from django-paranoid
# before
from django_paranoid.models import ParanoidModel
# after
from x_paranoid.models import XParanoidModel
- Find
unique=Truethat will be soft-deleted → replace withParanoidUniqueConstraint. - Add
XParanoidMetaonly where you need custom restore or TTL. - Add
XParanoidSerializerMixinto serializers that hituniqueerrors. python manage.py makemigrations && python manage.py migrate
How it compares
- vs
django-paranoid: keeps the samedelete()/restore()idea but fixesfilter().delete()hard-delete,uniqueafter delete, restore collisions, cascade batch, DRF/Admin/TTL. UseXParanoidModelonly (noParanoidModelalias). - vs
django-safedelete:safedeletehas more policies (HARD_DELETE,NO_DELETE) and a bigger test suite, but itsSOFT_DELETE_CASCADErestores all children;x-django-paranoidrestores only the batch that was deleted together. Simpler, partial-index first.
For full trade-offs and edge cases (deep cascade, XParanoidMeta inheritance, auto M2M) see PROject.md.
Development
python manage.py test tests --verbosity=2
python manage.py check
python -m build && twine check dist/*
Requires Python 3.9+, Django 3.2+. Partial indexes need Postgres 9.5+ or SQLite 3.8+.
License
MIT — preserves original django-paranoid MIT (Carlos Ganoza). See LICENSE.
Release files for x-django-paranoid 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| x_django_paranoid-0.2.0.tar.gz | 11.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| x_django_paranoid-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.9 kB
Release files / x_django_paranoid-0.2.0.tar.gz
| Download URL | x_django_paranoid-0.2.0.tar.gz |
|---|---|
| Size | 11.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
abe4d778ff5e24ad6cb141a29d15af69b38d6a1dc791c38c3b948d2021dc8ff3
|
|
BLAKE2b-256 checksum How to use checksums |
574d9932d43826ccc7c7dd4c88cf22cb831970da93e385f33ffe8050b2961f49
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / x_django_paranoid-0.2.0-py3-none-any.whl
| Download URL | x_django_paranoid-0.2.0-py3-none-any.whl |
|---|---|
| Size | 12.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
662fbfd4e1752d21c0e4612eacd75e05e48b8484e16c8cae846ffc23d98a2a74
|
|
BLAKE2b-256 checksum How to use checksums |
c3b1ccbf8021d19a4ca05eba6f223d75b7d8ac6170b8290fc0574d7581e862bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|