In Django, when does a ManyToManyField need an explicit through model, and how does it change adding and removing related rows?
answer
- data that belongs to the link
- the hidden join table
- through_defaults on add()
- remove() and duplicate pairs
basics
~20 sUse 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 sWithout `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 linesfrom 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 ordergo deeper
Recall that a ManyToManyField is stored in a join table and that through= lets you supply your own model for it.
Explain when link data forces a through model, how through_defaults works on add() and set(), and why pair uniqueness must be declared.
Choose on_delete for both keys of the intermediary, plan the migration from an automatic join table, and guard against duplicate links.
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.