A deletion should not be a log line saying something is gone. In our audit recorder it is the full inventory of what was lost.
The Django adapter treats a delete as the mirror of a create. A creation is a diff from nothing to a snapshot. A deletion is a diff from a snapshot to nothing. We pass the stored snapshot and an empty mapping into the same diff function, so every audited field arrives with its old value on one side and MISSING on the other, and each change reports itself as a removal. No special-case branch, and no second code path to keep in sync with the first.
Three details mattered more than the diff itself.
The primary key has to be read while the signal still has it. The delete collector clears it once the delete completes, so the record captures the pk at post_delete time. A deletion entry you cannot join back to the row it describes is just noise.
Snapshotting must not issue queries. We walk the model's concrete fields, skip the deferred ones, and read relations by their attribute name instead of touching the related object. An audit layer that lazily loads a foreign key during teardown quietly turns one delete into N queries.
Unregistered models emit nothing. The handler looks up the sender in the registry first and returns silently if the model is not in it. Audit coverage is opt-in, not ambient.
After that, the ordering is the boring part done deliberately: redaction withholds sensitive values before anything leaves the recorder, and the actor is resolved exactly as it is on a save, so a delete row is attributable on the same terms as an update. All three signals attach and detach under one dispatch id, which is what makes unregister symmetric with register rather than a partial cleanup.
This came out of our open-source project model-audit, where the rule is that a destructive action should produce the most detailed record in the system, not the least.
If you keep audit logs, the check worth running is whether a deleted record still tells you what it contained, or only that it once existed.