ForeignKey Relationships
Every TaskFlow task belongs to exactly one project, and a project can naturally hold many tasks. That
shape — one record on one side, many records pointing back at it — is called a
one-to-many relationship, and it's by far the most common kind of relationship you'll
model in Django. The tool for it is ForeignKey.
Defining the Relationship
A ForeignKey is defined on the "many" side of the relationship — here, that's
Task, since many tasks can belong to one project. The field points at the model on the
"one" side:
# taskflow/models.py
class Project(models.Model):
name = models.CharField(max_length=150)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
class Task(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
due_date = models.DateField(null=True, blank=True)
priority = models.CharField(max_length=10)
completed = models.BooleanField(default=False)
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name='tasks'
)
Notice Project itself has a ForeignKey too — owner, pointing at
Django's built-in User model. One user can own many projects, so it's the same one-to-many
shape again. A model can have as many ForeignKey fields as it needs relationships.
The on_delete Argument
on_delete is not optional — Django requires you to say explicitly what should happen to
a Task if the Project it points to gets deleted. This one decision has real
consequences for your data, so it's worth understanding the common choices:
| Option | Behavior when the related row is deleted |
|---|---|
models.CASCADE | Deletes the tasks too — if a Project is removed, every Task pointing at it goes with it. The most common choice for "this child record has no meaning without its parent." |
models.PROTECT | Refuses the deletion outright — Django raises a ProtectedError and blocks removing the Project while it still has Tasks attached, forcing you to handle those tasks first. |
models.SET_NULL | Sets the foreign key to NULL instead of deleting the related row. Requires the field to also declare null=True, since the column must be allowed to hold no value. |
For TaskFlow, CASCADE makes sense on Task.project — a task genuinely doesn't
belong anywhere once its project is gone. If you wanted tasks to survive a deleted project instead
(say, to move them into an "unassigned" bucket), you'd use SET_NULL with
null=True on the project field.
related_name: Querying Backwards
A ForeignKey gives you an obvious forward path — from a Task
to its Project. But Django also automatically creates a reverse path,
from a Project back to all the Tasks that point at it. related_name
is what names that reverse path.
Without related_name, Django falls back to a default reverse accessor built from the
model's name in lowercase, plus _set — for Task, that would be
my_project.task_set.all(). It works, but it reads awkwardly and doesn't say anything about
what the relationship actually means. Setting related_name='tasks' replaces that with
something that reads naturally:
# Forward: from a Task to its Project
my_task.project.name
# Reverse, WITH related_name='tasks' set on the ForeignKey:
my_project.tasks.all()
# Reverse, WITHOUT related_name (the awkward Django default):
my_project.task_set.all()
related_name costs nothing to add and makes every reverse query in your codebase read
like plain English — it's worth setting deliberately on every ForeignKey you define.
A Full Worked Example: Both Directions
Putting it together — creating a project, attaching a few tasks to it, then reading the relationship from both sides:
# Create a project owned by the current user
redesign = Project.objects.create(
name='Website Redesign',
description='Refresh the marketing site',
owner=request.user
)
# Create tasks linked to it via the project ForeignKey
Task.objects.create(title='Wireframe the homepage', priority='high', project=redesign)
Task.objects.create(title='Pick a new color palette', priority='medium', project=redesign)
Task.objects.create(title='Write updated copy', priority='medium', project=redesign)
# FORWARD: from a task, find its project
first_task = Task.objects.first()
print(first_task.project.name) # 'Website Redesign'
# REVERSE: from the project, find all of its tasks
print(redesign.tasks.count()) # 3
for t in redesign.tasks.all():
print(t.title)
# The reverse relationship is a full QuerySet, so it chains with everything
# you've already learned — filtering, ordering, and so on:
redesign.tasks.filter(completed=False).order_by('due_date')