Filter concepts by levelShowing all levels.

Django · Section 15

on_delete Behavior

Level
intermediate
Read
16 min
Concepts
2

The seven on_delete options a ForeignKey/OneToOneField can take — CASCADE, PROTECT, RESTRICT, SET_NULL, SET_DEFAULT, SET(...), DO_NOTHING — what each actually does and what it requires, plus the roadmap's own explicit framing: the right choice for a given relationship comes from a real business/data requirement ("what should happen to an order if its customer is removed?"), not from whichever option is quickest to write.

What is true here

  1. CASCADE deletes the referencing row too; PROTECT always blocks the deletion; RESTRICT blocks only a direct, isolated deletion, allowing one resolved by a cascade elsewhere in the same operation.
  2. SET_NULL (needs null=True), SET_DEFAULT (needs default=), and SET(callable) all keep the referencing row but change what it points to.
  3. DO_NOTHING takes no Django-level action — the outcome depends entirely on whatever constraint (if any) the database itself enforces.
  4. CASCADE is the easiest option to write (no null=True, no default= needed) — that ease is not evidence it is the correct choice for a given relationship.
  5. The right on_delete comes from asking what the business/data actually requires when the parent is deleted, answered before the field is written, not from habit.

What you will be able to do

  • Choose correctly among the seven on_delete options for a given ForeignKey
  • Explain the PROTECT-vs-RESTRICT distinction and when RESTRICT is the correct one
  • Use SET(callable) to point a deleted reference at a sentinel object instead of NULL
  • Derive an on_delete choice from an actual business requirement rather than the path of least code

The seven options

What each on_delete value actually does, and what it requires of the field.

The seven on_delete options

coreintermediate

on_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.

python
customer = models.ForeignKey("Customer", on_delete=models.PROTECT)
coupon = models.ForeignKey("Coupon", on_delete=models.SET_NULL, null=True)

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.

accounts/models.pypython
def get_sentinel_user():
    return get_user_model().objects.get_or_create(username="deleted")[0]

class Order(models.Model):
    placed_by = models.ForeignKey(
        "auth.User",
        on_delete=models.SET(get_sentinel_user),
        related_name="orders",
    )
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

python
on_delete=models.SET(get_sentinel_user())   # called immediately, at import time

Better

python
on_delete=models.SET(get_sentinel_user)   # the callable itself

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.

Seven answers to "the row I point to is gone — what happens to ME?"

CASCADE

I go too

PROTECT / RESTRICT

stop — RESTRICT allows a cascade-resolved delete

SET_NULL / SET_DEFAULT / SET(x)

I survive, my reference changes

  1. CASCADE — I go too
  2. PROTECT / RESTRICT — stop — RESTRICT allows a cascade-resolved delete
  3. SET_NULL / SET_DEFAULT / SET(x) — I survive, my reference changes

The seven on_delete options

The seven on_delete options
OptionEffectExtra requirement
CASCADEdelete the referencing row toonone
PROTECTalways block the deletion (ProtectedError)none
RESTRICTblock unless also resolved via a cascade elsewherenone
SET_NULLset the reference to NULLnull=True
SET_DEFAULTset the reference to the field's defaultdefault=...
SET(value_or_callable)set the reference to a fixed value or callable resultthe value/callable must exist
DO_NOTHINGno Django-level actionrelies on the database's own constraint, if any

Together

python
class Order(models.Model):
    customer = models.ForeignKey("Customer", on_delete=models.PROTECT)
    placed_by = models.ForeignKey(
        "auth.User", on_delete=models.SET(get_sentinel_user), null=True,
    )
    coupon = models.ForeignKey("Coupon", on_delete=models.SET_NULL, null=True, blank=True)

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

Advertisement

Choosing deliberately

Deriving the right choice from a business rule instead of reaching for whatever compiles with the least extra configuration.

Choosing on_delete from a business rule, not habit

standardintermediate

CASCADE is the easiest option to reach for, but it is a data-loss decision, not a neutral default — the right on_delete for a given ForeignKey comes from asking what the BUSINESS actually needs when the parent is removed, not from picking whatever compiles fastest. "What should happen to an order if its customer is deleted?" has a real answer (usually: nothing should be lost, so PROTECT or SET_NULL, not CASCADE), and that answer should come before the field is written.

Think of it as

Every on_delete choice is really a data-retention policy in disguise. CASCADE says financial/audit history is disposable — fine for a Comment tied to a Post (delete the post, the comments have no independent meaning), wrong for an Order tied to a Customer (deleting a customer should almost never silently erase their entire purchase history, tax records, and revenue). The habit to break is reaching for CASCADE because it "just works" in development with a small, disposable dataset — the same choice in production, on records with legal/financial/audit weight, quietly destroys data nobody meant to delete.

python
# ask first: "what SHOULD happen to this row if its parent is deleted?"
# then pick on_delete to match that answer — not the other way around

What we're doing: Decide on_delete for an Order's Customer ForeignKey by working through the actual business requirement first, not defaulting to CASCADE.

orders/models.pypython
class Order(models.Model):
    customer = models.ForeignKey("Customer", on_delete=models.PROTECT)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    placed_at = models.DateTimeField(auto_now_add=True)
2
PROTECT here encodes an actual business rule: "a customer with existing orders cannot simply be deleted" — the alternative, CASCADE, would silently destroy financial/order history the moment someone deletes a Customer row, which is very rarely what anyone actually wants.

Why this works: The roadmap's own framing question — "what should happen to an order if its customer is removed?" — has a real, defensible answer for most businesses (orders are financial records; they should not vanish because someone deleted a customer account), and PROTECT is the on_delete option that encodes exactly that answer, forcing an explicit decision (delete the orders first, or reassign them) instead of silent cascading loss.

Defaulting to CASCADE because it requires the least additional setup

Wrong

python
customer = models.ForeignKey("Customer", on_delete=models.CASCADE)
# "it compiled, ship it" — no null=True needed, no default= needed, easiest to write

Better

python
customer = models.ForeignKey("Customer", on_delete=models.PROTECT)
# forces an explicit decision about what happens to existing orders before a Customer can be removed

What you see: A support engineer deletes a test/duplicate Customer row in production and every real order, invoice, and payment record tied to that customer disappears with it — discovered only when finance asks where the revenue history went.

Why: CASCADE requires no extra field configuration (no null=True, no default=), which makes it the path of least resistance while writing the model — but "least code to write" and "correct data-retention behavior" are unrelated questions, and conflating them is exactly the "habit, not requirements" trap the roadmap calls out explicitly.

Matching on_delete to what the data relationship actually means

Matching on_delete to what the data relationship actually means
RelationshipWhat "delete the parent" should meanLikely on_delete
Post → Commentcomments have no independent meaningCASCADE
Customer → Order (with financial history)orders must be retained regardlessPROTECT (or SET_NULL if the account, not the history, is being erased)
User → their uploaded File (GDPR deletion request)the file may need to survive account deletionSET_NULL, with the file re-attributed to a sentinel account
Album → Song, where Song also cascades from Artista Song already going away via Artist shouldn't separately block Album's deletionRESTRICT

Together

python
class Order(models.Model):
    # deleting a Customer must never silently delete their financial history
    customer = models.ForeignKey("Customer", on_delete=models.PROTECT)

class Comment(models.Model):
    # a comment has no meaning once its post is gone
    post = models.ForeignKey("Post", on_delete=models.CASCADE)

Remember: CASCADE, PROTECT, and SET_NULL are all correct choices — for different business rules. Ask "what should happen here?" before picking one; CASCADE being the least code to write is not evidence it is the right answer.

See also: the seven on delete options · the three relationship fields · instance methods and domain logic

Advertisement