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 ManyToManyField from the previous tutorial.
  • Your .venv must 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 the Enrollment model as the junction table instead of creating one automatically.
  • The through model must have a ForeignKey to each side of the relationship.
  • unique_together in Meta ensures 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)

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 ManyToManyField and 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.


Never miss a story on Django.wiki

Subscribe for fresh tutorials, snippets, and updates.

By subscribing you agree to our Privacy Policy.