Many To Many With Through Models
Customize ManyToMany relations with an explicit through model. Add extra fields on the relation, manage constraints, and handle admin inlines.
1. Introduction
A standard ManyToManyField works well when you only need to know that two records are connected. But sometimes the relationship itself carries extra information. For example, a student enrolls in a course — and you need to store the enrollment date, the grade, or whether they completed it.
This is where a through model comes in. It replaces Django's automatically generated junction table with a model you define yourself, so you can add fields to the relationship.
- You should already understand
ManyToManyFieldfrom the previous tutorial. - Your
.venvmust be active and Django 5.2 installed.
2. When a standard ManyToManyField is not enough
Consider a simple article and tag relationship:
class Article(models.Model):
tags = models.ManyToManyField('Tag', blank=True)
This works fine if you only need to know which tags an article has. But what if you also need to store when a tag was added to the article, or who added it? A standard ManyToManyField cannot hold that information — its junction table only has two columns: the article ID and the tag ID.
A through model solves this by giving you full control over the junction table.
3. Defining a through model
Here is an example where a student can enroll in many courses, and each enrollment has a date and a status:
# pages/models.py
from django.db import models
class Student(models.Model):
name = models.CharField(max_length=200)
email = models.EmailField(unique=True)
def __str__(self):
return self.name
class Course(models.Model):
title = models.CharField(max_length=200)
students = models.ManyToManyField(
Student,
through='Enrollment',
related_name='courses',
)
def __str__(self):
return self.title
class Enrollment(models.Model):
class Status(models.TextChoices):
ACTIVE = 'active', 'Active'
COMPLETED = 'completed', 'Completed'
DROPPED = 'dropped', 'Dropped'
student = models.ForeignKey(Student, on_delete=models.CASCADE)
course = models.ForeignKey(Course, on_delete=models.CASCADE)
enrolled_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(
max_length=20,
choices=Status,
default=Status.ACTIVE,
)
class Meta:
unique_together = [('student', 'course')]
def __str__(self):
return f'{self.student.name} in {self.course.title}'
The key parts:
through='Enrollment'— tells Django to use theEnrollmentmodel as the junction table instead of creating one automatically.- The through model must have a
ForeignKeyto each side of the relationship. unique_togetherinMetaensures a student can only enroll in the same course once.
4. Creating and querying records
With a through model, you can no longer use .add(), .remove(), or .set() directly on the ManyToManyField. You must create and delete Enrollment records directly:
# Create an enrollment
student = Student.objects.get(id=1)
course = Course.objects.get(id=1)
Enrollment.objects.create(
student=student,
course=course,
status=Enrollment.Status.ACTIVE,
)
# Get all courses for a student
student.courses.all()
# Get all students in a course
course.students.all()
# Get enrollments with extra data
enrollments = Enrollment.objects.filter(
course=course,
status=Enrollment.Status.ACTIVE,
).select_related('student')
for enrollment in enrollments:
print(enrollment.student.name, enrollment.enrolled_at)
student.courses.all() and course.students.all() still work — Django traverses the through model automatically. But to access the extra fields on the enrollment, you need to query Enrollment directly.
5. Removing a relationship
To remove a student from a course, delete the Enrollment record directly:
Enrollment.objects.filter(student=student, course=course).delete()
6. Through model vs standard ManyToManyField
- Use a standard ManyToManyField when you only need to know the connection exists — for example tags on an article, or permissions on a group.
- Use a through model when the relationship itself carries data — enrollment dates, order quantities, membership roles, or any other extra field.
- You can always upgrade later — starting with a standard
ManyToManyFieldand switching to a through model later is possible, but it requires a migration and some data handling. If you think you will need extra fields, start with a through model from the beginning.
7. Through models in the admin
When you use a through model, the standard many-to-many widget in the admin is replaced. You need to use an inline instead. Register the through model as a TabularInline on the parent model:
# pages/admin.py
from django.contrib import admin
from .models import Course, Enrollment
class EnrollmentInline(admin.TabularInline):
model = Enrollment
extra = 1
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
inlines = [EnrollmentInline]
8. Next steps
You now know how to use through models to add extra data to many-to-many relationships. The next step covers model Meta options — how to control table names, ordering, verbose names, and more using the inner Meta class.