CodeOath
← All posts
Python65 min total · 18 parts

Django Fundamentals: The ORM, Migrations, and Shipping a Real App

Part 8 of 18 · ~2 min

Relationships: Who Owns What, and What Happens on Delete

class Tool(models.Model):
    owner = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="tools_listed")
    approved_by = models.ForeignKey(
        "auth.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="tools_approved"
    )
    category = models.ForeignKey("Category", on_delete=models.PROTECT, related_name="tools")
    tags = models.ManyToManyField("Tag", related_name="tools", blank=True)
    name = models.CharField(max_length=100)


class Profile(models.Model):
    user = models.OneToOneField("auth.User", on_delete=models.CASCADE)
    neighborhood = models.CharField(max_length=100, blank=True)

Why Loan.borrower uses PROTECT and Tool.owner uses CASCADE

models.CASCADE       # delete this row too, when the related row disappears
models.PROTECT        # refuse the delete outright — raises ProtectedError
models.SET_NULL       # null out this FK (the field needs null=True)
models.SET_DEFAULT     # fall back to the field's default value

Picking CASCADE for every foreign key without a second thought is the easy path, and Priya nearly took it on Loan.borrower before realizing what it would actually do. CASCADE genuinely is the right call for Tool.owner — if a member deletes their ToolShed account, it makes sense for the tools they listed to go with them; nobody else has any claim on a drill that was never theirs. It is a genuinely bad default for Loan.borrower: with CASCADE there, the moment that same member deletes their account, every Loan row where they're the borrower disappears — including the still-open record that they currently have someone else's ladder and haven't returned it yet. PROTECT refuses that delete outright and raises ProtectedError instead — verified directly — which forces whatever's deleting the account to deal with open loans first, rather than quietly losing the one record that says where the ladder actually is.

Tool.approved_by — whichever moderator signed off on a listing — gets SET_NULL instead of either: the tool listing itself should survive a moderator's account being deleted, it just no longer needs to remember who approved it. SET_NULL requires null=True on the field itself, which is easy to forget and produces an immediate, unambiguous error at migration time if you do.

related_name, and the clash Django won't let you ship

Every ForeignKey quietly creates a way to query backwards, from the model it points at — accessible as <lowercase model>_set unless a related_name is supplied, in which case that name replaces it entirely:

priya = User.objects.get(username="priya")
priya.tools_listed.all()     # every Tool Priya owns, via related_name="tools_listed"
# without related_name, this would default to priya.tool_set.all()

Tool has two separate foreign keys pointing at Userowner and approved_by. Leave both without an explicit related_name and both would default to the identical reverse accessor, user.tool_set, and Django refuses to even start the app over it — verified directly, this is check fields.E304: "Reverse accessor 'User.tool_set' for 'catalog.Tool.owner' clashes with reverse accessor for 'catalog.Tool.approved_by'." That's the concrete reason related_name shows up explicitly on real foreign keys constantly, rather than being left at its default the way a single, unambiguous FK usually can be.