skip to content

In Django, when does a ManyToManyField need an explicit through model, and how does it change adding and removing related rows?

level: middleimportance: should knowfreq 52%

answer

  1. data that belongs to the link
  2. the hidden join table
  3. through_defaults on add()
  4. remove() and duplicate pairs

basics

~20 s

Use a through model when the link itself carries data, such as an author's position and royalty share on a book. add(), create() and set() still work, but required link fields must come via through_defaults, and pair uniqueness is yours to declare.

solid answer

~40 s

Without `through`, Django generates a hidden join model with an `id`, two foreign keys and a unique constraint on the pair; `Book.authors.through` exposes it. When the relation carries its own data, such as co-author order or royalty share, you declare an intermediary model with foreign keys to both sides and pass `through="Authorship"`. You can still call `book.authors.add(author, through_defaults={...})`, `create()` and `set()`, supplying required link fields in `through_defaults`, or create `Authorship` rows directly. The custom model gets no automatic uniqueness, so add a `UniqueConstraint` on the pair if duplicates are wrong; without it, `remove()` deletes every row for that pair. Delete behaviour now comes from the `on_delete` you choose on the two foreign keys.

code

python · 29 lines
python
from decimal import Decimal

from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=200)


class Book(models.Model):
    title = models.CharField(max_length=300)
    authors = models.ManyToManyField(Author, through="Authorship", related_name="books")


class Authorship(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE)
    author = models.ForeignKey(Author, on_delete=models.PROTECT)
    position = models.PositiveSmallIntegerField()
    royalty_share = models.DecimalField(max_digits=5, decimal_places=2)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["book", "author"], name="unique_book_author"),
        ]


# book.authors.add(author, through_defaults={"position": 2, "royalty_share": Decimal("0.25")})
# Authorship.objects.create(book=book, author=other, position=1, royalty_share=Decimal("0.75"))
# book.authorship_set.order_by("position")  # link rows in cover order

go deeper

for a junior

Recall that a ManyToManyField is stored in a join table and that through= lets you supply your own model for it.

for a middle

Explain when link data forces a through model, how through_defaults works on add() and set(), and why pair uniqueness must be declared.

for a senior

Choose on_delete for both keys of the intermediary, plan the migration from an automatic join table, and guard against duplicate links.

for a principal

Judge when a many-to-many should be modelled as a first-class entity from day one, since links in a catalog tend to gain attributes.

## What Django builds by default A `ManyToManyField` needs a **join table**: one row per link between the two sides. If you declare only `authors = models.ManyToManyField(Author)` on `Book`, Django creates that table from an **automatic intermediary model** with three fields: - `id`, the link's primary key; - `book_id` and `author_id`, foreign keys to both sides; plus a **unique constraint** on the pair, so the same author cannot be linked twice. The model is reachable as `Book.authors.through`, and its foreign keys cascade: deleting a book removes its link rows. ## When you need your own The trigger is **data that belongs to the link, not to either side**. In a publisher's catalog, a book has several co-authors, and for each pairing you need: - the author's **position** on the cover (first author, second author); - the **royalty share** that author receives for that book; - perhaps a **role** (writer, illustrator, editor). None of these belongs on `Author` (it differs per book) or on `Book` (it differs per author). So you declare an **intermediary model**, `Authorship`, with a foreign key to each side plus those fields, and point the relation at it with `ManyToManyField(Author, through="Authorship")`. Rules Django enforces on the intermediary: 1. It must have exactly one foreign key to each side, or you must say which ones to use with `through_fields`. 2. For a relation from a model to itself, two keys to that model are allowed and taken as source then target, unless `through_fields` says otherwise. ## How writing links changes | Operation | Automatic through | Custom through | |---|---|---| | `book.authors.add(a)` | inserts a link row | works; required link fields must come in `through_defaults={...}` | | `book.authors.create(name=...)` | creates author and link | works, with `through_defaults` for link fields | | `book.authors.set([...])` | replaces links | works, with `through_defaults` for new links | | `Authorship.objects.create(...)` | possible via `.through` | the natural way when each link has its own values | | duplicate pair | blocked by the built-in unique constraint | allowed unless you add a `UniqueConstraint` | | `book.authors.remove(a)` | deletes that one link | deletes **every** link row for that pair | `through_defaults` applies the same values to every link created in that call, which suits defaults but not per-author values. When each co-author gets a different share, create the `Authorship` rows directly. ## Deleting and reading - The `on_delete` rules are now **yours**. `Authorship.book = CASCADE` removes links with the book; `Authorship.author = PROTECT` stops you deleting an author who still has books, which a catalog usually wants. - `book.authors.all()` returns `Author` rows and ignores link fields. To list authors in cover order, read the link rows themselves, for example `book.authorship_set.order_by("position")`, and take `.author` from each. - The reverse side follows the usual naming rules: `related_name="books"` on the field gives `author.books`. ## A worked sequence With the models in the code example, a typical editing session looks like this: ```python book.authors.add(lead_author, through_defaults={"position": 1, "royalty_share": Decimal("0.60")}) Authorship.objects.create(book=book, author=co_author, position=2, royalty_share=Decimal("0.40")) book.authors.count() # 2, counted through the Authorship table book.authors.remove(co_author) # deletes the Authorship row, keeps the Author co_author.delete() # allowed only if no Authorship row still references them (PROTECT) ``` Each call goes through the related manager or the intermediary model, and each touches only link rows unless you delete one of the sides. ## Common mistakes - Adding the intermediary model later without a plan: turning an existing automatic join table into a custom model is a schema change that has to preserve the existing rows, and it belongs in a carefully written migration. - Forgetting the `UniqueConstraint`, then finding the same author listed twice on a cover after a double-submitted form. - Putting link data on `Author` "for now", then discovering it differs per book. - Expecting `add()` to fill required link fields by itself: without `through_defaults` the insert fails on the missing value. A through model is the Django answer to "the relationship has attributes"; the interview test is whether you recognise that moment and know what the related manager does once it arrives.

  • Your Authorship model has two ForeignKeys to Author, author and contributed_by. What does Django need from you?
    It can no longer tell which key forms the relation, so the checks fail until you pass `through_fields=("book", "author")` on the `ManyToManyField`. The first name is the key to the model that declares the field, the second the key to the target; `contributed_by` then stays an ordinary foreign key.
  • Why might book.authors.remove(author) delete more than one Authorship row?
    `remove()` deletes every intermediary row for that book and author pair. A custom through model has no automatic uniqueness, so if duplicates were created, for instance by a double-submitted form, all of them go. A `UniqueConstraint` on the pair prevents the duplicates in the first place.

saying these in an interview costs you the question

  • A through model makes add() and set() unusable on the relation.
  • A custom through model gets a unique constraint on the pair automatically.
  • Link data such as royalty share belongs on the Author model.
  • book.authors.all() returns Authorship rows with their extra fields.
  • Without through, Django stores the links in a JSON column on the model.