Open-source project
makinacorpus/django-safedelete avatar
makinacorpus/django-safedelete

django-safedelete and the five ways to not really delete a row

Mask your objects instead of deleting them from your database.

708 stars130 forksPythonBSD-3-Clause

At a glance

What is it?
django-safedelete gives Django models an abstract base class where delete() can mask a row instead of removing it, and the policy is chosen per model from five constants. The catch is that masking is only half the problem, because every query you write has to agree about which rows are alive.
Who is it for?
django-safedelete is a good fit for a Django project that needs to keep deleted rows around for audit, undo or legal reasons and wants the delete call site to stay unchanged, because the model base class and the policy constant do the work. It is a poor fit for a project where deleted data must genuinely leave the database, since masked rows still occupy space and are still queryable by anyone who forgets the filter.
Can I use it commercially?
Yes. BSD-3-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 67 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

An abstract model that keeps the call site unchanged

The stated motivation is short: for various reasons, you may want to avoid deleting objects from your database. The application provides an abstract model that allows you to transparently retrieve or delete your objects, without having them deleted from your database.

Transparent is the key word. The usage example does not show a new API. It shows the ordinary delete call:

python
    from safedelete.models import SafeDeleteModel
    from safedelete.models import HARD_DELETE_NOCASCADE

and then, on a model that inherits from the base class, a call to delete() that masks the row rather than removing it.

The point is that the rest of your code does not know the difference. Your views, your admin actions and your management commands keep calling delete(). Whether that removes the row or flags it is decided by a class attribute on the model.

The audience is a Django developer with a requirement that data must not disappear: an audit trail, a soft-delete for user accounts, a requirement to be able to restore, or a compliance rule about keeping records. It is not a general query layer, and it is not a way of making your database faster.

Five policies, and they are not variations on one idea

You choose what happens on delete with the _safedelete_policy attribute, and the README lists five behaviours.

SOFT_DELETE is the default, and the object is masked from the database. SOFT_DELETE_CASCADE masks the object and also masks any dependent models. HARD_DELETE deletes the object normally. HARD_DELETE_NOCASCADE hard-deletes the object, but if its deletion would delete other objects, it is only masked instead. NO_DELETE never deletes or masks anything, and the README says to use it with caution.

The distinction that matters is between the two cascade policies. SOFT_DELETE_CASCADE is the one that keeps referential integrity consistent inside your soft-deleted world, at the cost of removing a whole graph of objects from every query. HARD_DELETE_NOCASCADE is the pragmatic middle: you get real deletion where it is safe, and masking only where a dependent row would otherwise go with it.

The example uses HARD_DELETE_NOCASCADE for both models, with the comment explaining that objects will be hard-deleted, or soft deleted if other objects would have been deleted too. That is the policy that most applications end up wanting, and it is the one to start with when you are unsure.

The example that shows masking in action

The README's worked example is a small many-to-many relationship, and it demonstrates the point better than any description of the policies.

Two models are defined, Article and Order, each with a name field and a _safedelete_policy set to HARD_DELETE_NOCASCADE. Order holds a ManyToManyField to Article.

The use sequence creates two articles, creates an order, and adds the first article to the order's article set:

python
    >>> order.articles.add(article1)

Then the two deletes differ. The README comments are explicit about why:

python
    # This article will be masked, but not deleted from the database as it is still referenced in an order.
    >>> article1.delete()
python
    # This article will be deleted from the database.
    >>> article2.delete()

The first article is still referenced by the order, so deleting it would take the other object with it, and under this policy it is masked instead. The second is unreferenced, so it goes for real.

This is the behaviour that makes the policy worth the indirection. In a plain Django model, delete() on the first article would collect the related objects, and you would have to decide at the call site whether that is acceptable. Here the decision is made once, declaratively, on the model.

Installing the app and the two settings that matter

Installation is from PyPI or from GitHub:

bash
    pip install django-safedelete

The GitHub route is an editable install:

bash
    pip install -e git://github.com/makinacorpus/django-safedelete.git#egg=django-safedelete

Then the app goes into INSTALLED_APPS:

python
    INSTALLED_APPS = [
        'safedelete',
        [...]
    ]

The README states plainly that the application does not have any special requirement, meaning no extra database backend and no migration machinery of its own beyond what adding the field to your models implies.

Two settings are documented. SAFE_DELETE_INTERPRET_UNDELETED_OBJECTS_AS_CREATED is a boolean in the main Django settings; if you activate it, the update_or_create() function from Django's standard manager class returns True for the created variable when the object was soft-deleted and is now revived. That is a subtle and useful option, because it changes what update_or_create means for a masked row.

The second is SAFE_DELETE_FIELD_NAME. By default the field indicating a database entry is soft-deleted is called deleted, and you can override the field name with this setting. If you already have a field called deleted, or you prefer a name like is_removed, this is the hook for it, and it is the one to check before you generate migrations.

The cost of masking is that every query has to agree

Soft delete is a well-known trade, and the trade is in the queries.

A masked row is still in the database. That is the whole point, and it is also the problem: any query written against that model has to exclude masked rows, or your application will keep showing deleted objects. The library's job is to make the default manager hide them, and the README's description of the abstract model as allowing you to transparently retrieve or delete your objects is exactly that promise.

The places where it bites are the ones that bypass the default manager. A raw query, a values() call that you then filter yourself, a report run against the table, an aggregate over all rows, and code that reaches for objects.all() on a related manager with custom filtering. Each of those is a place where a masked row can leak into a result set.

The second cost is storage. Masked rows are never reclaimed, and the library provides no documented purge or vacuum step. The README does not describe any management command for permanently removing old masked rows, so if you soft-delete on a high-volume path, your table grows without bound and clearing it is a problem you will solve yourself.

The third cost is the query plan. A filtered default manager on a hot table is not free, and the README does not discuss indexes on the mask field.

Version support, the release record and what the docs do not cover

The compatibility list in the README is thorough and worth reading, because the branch names map to Django versions rather than to feature sets.

Branch 0.2.x is compatible with Django 1.2 and later, 0.3.x with Django 1.4, 0.4.x with Django 1.8, 0.5.x with Django 1.11, and branches 1.0.x, 1.1.x and 1.2.x with Django 2.2. Branch 1.3.x is compatible with Django 3.2 and Python 3.7 or later, and the current branch is tested with Django 3.2 on Python 3.7 to 3.10, Django 4.0 and 4.1 on Python 3.8 to 3.10, and Django 4.2 on Python 3.8 to 3.11.

So the tested matrix tops out at Django 4.2. Newer Django versions are not listed as tested, and the README does not claim support for them.

The release record is thin. The only tagged release is 0.1.1 from 2013-07-05, which is clearly a release tag that predates the current numbering scheme, since the branches in use are 1.3.x. The repository is not archived, and the last push was on 2026-08-17, so work continues on master, but the released versions do not tell you the history of the current line.

The licence is BSD-3-Clause, and the README points at the LICENSE file for the terms and the AUTHORS file for contacts, with the project maintained by makina corpus. The documentation is published at django-safedelete.readthedocs.io and can be built locally with tox -e docs.

Editorial conclusion

django-safedelete is a good fit for a Django project that needs to keep deleted rows around for audit, undo or legal reasons and wants the delete call site to stay unchanged, because the model base class and the policy constant do the work. It is a poor fit for a project where deleted data must genuinely leave the database, since masked rows still occupy space and are still queryable by anyone who forgets the filter. Before adopting, decide which of the five policies matches each model, since a single global policy is the mistake this library punishes, and set SAFE_DELETE_FIELD_NAME if the word deleted would collide with a field you already have, then check the queries in the codebase for places that must exclude masked rows.

Frequently asked questions

How do I install django-safedelete?

Install it with pip install django-safedelete, or from GitHub with pip install -e git://github.com/makinacorpus/django-safedelete.git#egg=django-safedelete, then add 'safedelete' to INSTALLED_APPS. The application has no special requirements.

What deletion policies does django-safedelete offer?

Five: SOFT_DELETE as the default, SOFT_DELETE_CASCADE which also masks dependent models, HARD_DELETE, HARD_DELETE_NOCASCADE which masks instead when a dependent object would also be deleted, and NO_DELETE which the README says to use with caution.

Which Django versions does django-safedelete support?

The current branch 1.3.x is compatible with Django 3.2 and later and is tested with Django 3.2, 4.0, 4.1 and 4.2 on Python 3.7 to 3.11. Older branches map to older Django versions, from 0.2.x with Django 1.2 onwards.

Can I change the name of the field that marks a soft-deleted row?

Yes. By default the field indicating a database entry is soft-deleted is called deleted, and you can override the field name with the SAFE_DELETE_FIELD_NAME setting in your Django settings.

Official sources

  1. License: BSD-3-Clause
  2. makinacorpus/django-safedelete on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/makinacorpus-django-safedelete.svg)](https://hysenlabs.com/projects/makinacorpus-django-safedelete)

Was this useful?