The seven on_delete options
coreintermediateon_delete controls what happens to a row when the row it points to (via ForeignKey/OneToOneField) is deleted. CASCADE deletes it too; PROTECT/RESTRICT block the deletion instead (RESTRICT allows it if the block would itself be resolved by a cascading delete elsewhere); SET_NULL/SET_DEFAULT/SET(...) null out or replace the reference instead of deleting anything; DO_NOTHING does nothing at the Django level, relying on the database's own constraint (or lack of one).
Think of it as
Every option answers one question: "the row I point to is gone — what happens to ME?" CASCADE says "I go too." PROTECT and RESTRICT both say "stop — don't let this happen," but RESTRICT has an escape hatch: if I would also be deleted anyway by a CASCADE from somewhere else in the same operation, that's fine, only a DIRECT, isolated deletion is blocked. SET_NULL/SET_DEFAULT/SET(...) all say "I survive, but my reference changes" — to nothing, to a fixed default, or to a value from a callable, respectively. DO_NOTHING says "not my problem" — Django does nothing, so it is purely a matter of what the actual database schema enforces underneath.
What we're doing: Use SET() with a callable to point a deleted user's past orders at a sentinel "deleted user" account instead of losing the reference or blocking the deletion.
- 1
- get_sentinel_user is a callable, not called here — it runs only at the moment a User is actually deleted, not at import time.
- 7
- models.SET(get_sentinel_user) — passing the function itself, not get_sentinel_user() (a call), the same "pass the callable, not the result" pattern as default=uuid.uuid4 elsewhere in the ORM.
Why this works: SET_NULL would work too, but it loses the fact that this order WAS placed by a real (now-deleted) user, replacing it with an ambiguous NULL indistinguishable from "never had a user." Pointing at a sentinel "deleted" account instead preserves that the order genuinely had a placing user, satisfying an audit trail that a NULL cannot.
Calling the SET() callable instead of passing it
Wrong
Better
What you see: A database query (get_or_create) runs at Django startup / module import time, every single time the app starts, for a value that should only ever be computed when a delete actually happens — in the worst case, this fails outright if the database isn't even connected yet during import.
Why: get_sentinel_user() with parentheses calls the function immediately, when the class body executes at import time — Django needs the callable itself, so it can call it later, only at the moment a matching row is actually deleted, exactly parallel to the default=callable convention used elsewhere.
- CASCADE — I go too
- PROTECT / RESTRICT — stop — RESTRICT allows a cascade-resolved delete
- SET_NULL / SET_DEFAULT / SET(x) — I survive, my reference changes
The seven on_delete options
Together
Remember: CASCADE deletes along; PROTECT always blocks; RESTRICT blocks only a direct/isolated deletion, allowing one resolved by a cascade elsewhere; SET_NULL/SET_DEFAULT/SET(callable) replace the reference; DO_NOTHING defers entirely to the database's own constraint.
See also: choosing the right on delete · the three relationship fields · constraints and indexes

