ORM Lens

Django 6.1’s DB_CASCADE skips your post_delete handlers

FROWNINGdev · 31 October 2026

Django 6.1 added three on_delete policies that run in the database instead of in Python:

from django.db import models

class Attachment(models.Model):
    order = models.ForeignKey("shop.Order", on_delete=models.DB_CASCADE)
    file = models.FileField()

DB_CASCADE, DB_SET_NULL and DB_SET_DEFAULT put ON DELETE CASCADE / SET NULL / SET DEFAULT on the foreign-key constraint and let the database do the work. Deleting an Order becomes one DELETE statement. Django no longer loads every child row into memory first to find out what else it has to delete.

That speed has a cost, and the 6.1 release notes say so: DB_CASCADE does not send pre_delete or post_delete for the rows the database removes. Django never sees those rows, so it has nothing to send signals about.

What stops working

Most projects have at least one delete handler they no longer think about:

from django.db.models.signals import post_delete
from django.dispatch import receiver

from .models import Attachment

@receiver(post_delete, sender=Attachment)
def delete_file_from_storage(sender, instance, **kwargs):
    instance.file.delete(save=False)

With the ORM’s CASCADE, deleting an order deletes its attachments and calls this handler once per attachment, so the files leave S3 too. Switch the foreign key to DB_CASCADE and the rows still disappear, but the handler never runs. You get no error and no log line, and the files stay in the bucket.

The same goes for audit trails written on delete, search-index removal, cache invalidation, and counters kept in sync by signals. A receiver registered with no sender (“log every delete”) is also skipped for those rows.

Third-party apps count too. django-cleanup and django-simple-history both listen to post_delete. If they cover a model you move to DB_CASCADE, assume they stop covering the rows the database deletes.

Finding them before you switch

Grepping for post_delete gets you the handlers, but not which ones a given DB_CASCADE foreign key will silence. You also have to match each receiver’s sender against the models on the receiving end of the cascade, and remember that a sender-less receiver matches all of them.

orm-lens does that matching statically: it reads your source, so it needs no database and no django.setup(). Since py-1.13.8, cascade lists the delete handlers each DB_CASCADE relation skips:

$ uvx orm-lens cascade shop.Order
deleting one shop.Order row:
  CASCADE-deletes rows in (1):
    - shop.Attachment  via order (db)
  (db) = on_delete=DB_*: the database applies it, so Django sends no delete signals for those rows.
  ! 1 delete receiver(s) will NOT run for (db) rows:
    - post_delete shop.signals.delete_file_from_storage  for shop.Attachment  (shop/signals.py:6)

--format json puts the same list under skipped_receivers, so a CI step can fail on it. The MCP server’s cascade_preview tool returns it too, so an AI agent that proposes switching to DB_CASCADE gets told which handlers go quiet.

What the scan does not cover, so you know where to look by hand:

What to do with a handler that would be skipped

What you should not do is switch the policy and trust that the handlers still fire. The rows disappear either way, so nothing tells you the handler stopped running.


orm-lens is free and MIT: a VS Code extension, a CLI (pip install orm-lens) and an MCP server for AI agents, all working from your source. It also handles the older ways of writing a cascade. A positional ForeignKey(Order, models.CASCADE), the way django.contrib writes it, and DO_NOTHING, which leaves rows for the constraint to reject, are both reported since py-1.13.7.