A reusable Django mixin that replaces delete() with a tombstone-first hard delete: a placeholder row is written to the same table before the original is removed. FK/M2M references are reassigned to the placeholder automatically — zero config required.
pip install -e .The tombstone package has no migrations of its own — the four tombstone columns (is_tombstone, tombstoned_at, tombstone_origin_id, tombstone_label) are added to your model's own table when you run makemigrations.
No configuration needed. Just inherit and migrate:
from django.db import models
from tombstone import TombstoneMixin
class Order(TombstoneMixin, models.Model):
number = models.CharField(max_length=50)
total = models.DecimalField(max_digits=10, decimal_places=2)python manage.py makemigrations myapp
python manage.py migrateorder = Order.objects.get(pk=1)
placeholder = order.delete()
# placeholder.is_tombstone == True
# placeholder.tombstoned_at == <datetime>
# placeholder.tombstone_origin_id == "1" (original pk)
# placeholder.tombstone_label == "Deleted - Order"
# placeholder.number == "" (cleared — default behaviour)
# same row pk=1 still exists, updated in placeDefault behaviour (no config):
- Original row is updated in place — same PK, no new row created.
- All business fields are cleared to their defaults.
- FK references remain valid automatically because the PK never changes.
Works exactly like REST_FRAMEWORK or SIMPLE_JWT — add to your settings.py:
TOMBSTONE = {
'DEFAULT_REF_BEHAVIOR': 'keep', # 'keep' | 'delete'
'DELETED_LABEL_FORMAT': 'Deleted - %', # % is replaced by the resolved label value
}| Key | Default | Effect |
|---|---|---|
DEFAULT_REF_BEHAVIOR |
"keep" |
FK/M2M policy for any relation not listed on the model ("keep" or "delete") |
DELETED_LABEL_FORMAT |
"Deleted - %" |
Format string for the tombstone label; % is replaced by the resolved label value |
Resolution order — highest to lowest:
Model class attribute → TOMBSTONE in settings.py → built-in default
Override defaults on any model that needs a different policy:
class User(TombstoneMixin, models.Model):
name = models.CharField(max_length=200)
email = models.EmailField(unique=True)
# Per-relation policy (unlisted relations use DEFAULT_REF_BEHAVIOR)
tombstone_ref_behavior = {
"sessions": "delete", # cascade-delete related rows
"audit_logs": "keep", # no-op — leave untouched
}
# Field(s) used to build the human-readable tombstone label
tombstone_label_field = "name" # single field
# tombstone_label_field = ["title", "code", "contact_email"] # or multipleuser = User.objects.get(pk=42)
placeholder = user.delete()Steps inside a single atomic transaction:
pre_tombstonesignal fires.tombstone_labelis built from the live field values (before clearing).- All business fields on the row are cleared to their defaults.
is_tombstone=True,tombstoned_at=now(),tombstone_origin_id="42"are set.- The row is saved — same PK, no new row created.
- Related FK/M2M objects are handled per
tombstone_ref_behavior. post_tombstonesignal fires with the updated placeholder.
The same object (now a tombstone) is returned.
Maps the related accessor name to a behaviour.
Unlisted relations fall back to DEFAULT_REF_BEHAVIOR from TOMBSTONE settings (default: "keep").
| Behaviour | FK effect | M2M effect |
|---|---|---|
"delete" |
Related rows deleted | Through-table rows deleted |
"keep" |
No-op | No-op |
tombstone_ref_behavior = {
"memberships": "delete", # cascade-delete when this record is tombstoned
"books": "keep", # leave books untouched
}Fields with unique=True are automatically UUID-suffixed when the row is tombstoned, freeing the slot for future records. No configuration needed.
# Before: code = "SKU-001"
# After: code = "__tombstoned__a3f9...uuid...7c1"Specifies which field(s) to read when building the human-readable label stored in tombstone_label. Values are read before business fields are cleared.
Single field — point at any field on any model:
# Invoice
tombstone_label_field = "invoice_number"
# placeholder.tombstone_label → "Deleted - INV-0042"
# Project
tombstone_label_field = "project_name"
# placeholder.tombstone_label → "Deleted - Website Redesign"
# Any field whose name contains 'email' → value wrapped in brackets
tombstone_label_field = "contact_email"
# placeholder.tombstone_label → "Deleted - (billing@acme.com)"Multiple fields — smart fallback chain:
When a list is provided, the system collects values and applies this chain:
- Join all non-empty non-email fields (whatever is available — one, some, or all)
- If no non-email field has a value, use any email field wrapped in brackets
- If nothing has a value, use the model class name
# Works for any model — invoice, project, order, subscription, etc.
tombstone_label_field = ["title", "code", "reference", "contact_email"]| Scenario | tombstone_label value |
|---|---|
title="Project Alpha", code="PRJ-001", reference="REF-001" |
"Project Alpha PRJ-001 REF-001" |
title="Project Alpha", code="", reference="REF-001" |
"Project Alpha REF-001" |
title="", code="PRJ-001", reference="" |
"PRJ-001" |
title="", code="", reference="", contact_email="ops@co.com" |
"(ops@co.com)" |
| All fields empty | Model class name |
Format controlled globally via DELETED_LABEL_FORMAT:
TOMBSTONE = {
'DELETED_LABEL_FORMAT': 'Deleted - %', # default — % replaced by label value
}| Format | Label value | Result |
|---|---|---|
"Deleted - %" |
"INV-0042" |
"Deleted - INV-0042" |
"Archived: %" |
"Project Alpha" |
"Archived: Project Alpha" |
"%" |
"Order" |
"Order" |
Automatically set on every placeholder. Stores the original pk as a string — useful for audit trails and restoration.
placeholder.tombstone_origin_id # "42"The default manager excludes tombstone records automatically. Bulk .delete() also goes through tombstone creation — it does not issue a raw SQL DELETE.
User.objects.all() # live records only
User.objects.tombstones() # placeholder records only
User.objects.all_records() # everything (migrations / admin)
User.objects.filter(is_active=False).delete() # creates tombstones, not raw DELETEfrom tombstone import pre_tombstone, post_tombstone
def on_post(sender, instance, placeholder, **kwargs):
cache.delete(f"user:{instance.pk}") # cache invalidation
search_index.remove(instance.pk) # search index cleanup
post_tombstone.connect(on_post, sender=User)| Signal | Arguments | When |
|---|---|---|
pre_tombstone |
sender, instance |
Before transaction begins |
post_tombstone |
sender, instance, placeholder |
After transaction commits |
from tombstone import TombstoneError| Scenario | Result |
|---|---|
.delete() called on a placeholder |
TombstoneError |
Invalid value in tombstone_ref_behavior |
TombstoneError |
| Recursive deletion cycle (A → B → A) | Cycle detected and broken automatically |
- Add
TombstoneMixinas the first base class beforemodels.Model. - Run
makemigrations/migrate— three columns are added; existing rows are unaffected. - Optionally configure
tombstone_ref_behaviorandtombstone_label_fieldper model. - Optionally set global defaults in
settings.pyunderTOMBSTONE.
# Minimal onboarding — zero config
class Invoice(TombstoneMixin, models.Model):
number = models.CharField(max_length=50, unique=True)
total = models.DecimalField(max_digits=10, decimal_places=2)
# Full onboarding — explicit per-model policy
class Invoice(TombstoneMixin, models.Model):
number = models.CharField(max_length=50, unique=True)
total = models.DecimalField(max_digits=10, decimal_places=2)
tombstone_label_field = "number"
tombstone_ref_behavior = {
"line_items": "delete",
"payments": "keep",
}| Limitation | Detail |
|---|---|
| GenericForeignKey / GenericRelation | Not supported. Handle via post_tombstone signal manually. |
| Bulk delete performance | QuerySet.delete() iterates per object. Correct but not optimal for very large sets. |
| Non-integer PKs | tombstone_origin_id stores str(pk) — works with UUID and integer PKs alike. |
| tombstone_label max length | tombstone_label is capped at 512 characters. Truncate long field values before tombstoning if needed. |
python3 -m venv .venv && source .venv/bin/activate
pip install -e . pytest pytest-django
pytest