Many-to-Many Relationships
A many-to-many relationship - an article has many tags, a tag is on many articles - is the one relationship the database does not store as a direct column. It needs a join table (the article_tag table with an article_id and a tag_id row per association), and Peewee's ManyToManyField is the declaration that creates and manages that table for you. This chapter covers the field, the two directions of access, the add/remove semantics, and the one structural fact that drives the queries: a many-to-many is two one-to-many relationships sharing a middle table.
Declaring the ManyToManyField
from peewee import *
class Tag(Model):
name = CharField(unique=True)
class Meta:
database = db
class Article(Model):
title = CharField()
body = TextField()
# one declaration creates the join table:
tags = ManyToManyField(Tag, backref="articles")
class Meta:
database = db
# Peewee builds the through table (named from the two models):
# CREATE TABLE article_tag (
# id INTEGER PRIMARY KEY,
# article_id INTEGER NOT NULL REFERENCES article (id),
# tag_id INTEGER NOT NULL REFERENCES tag (id),
# UNIQUE (article_id, tag_id)
# )
# plus indexes on both FK columns
The ManyToManyField(Tag) on Article is the whole declaration: Peewee creates the join table (its name derived from the two models), the two FK columns with their constraints, and the uniqueness on the pair (an article-tag association exists at most once). The backref="articles" gives the reverse direction on Tag. The through argument (below) is the variant where the join table carries its own data. The naming follows the one-to-many conventions: the field is named for the related model in plural (tags), the backref is the reverse plural (articles), and neither collides with a field on the declaring model.
Using It: Both Directions
# create the association: add tags to an article
art = Article.get(Article.id == 1)
python_tag, _ = Tag.get_or_create(name="python")
django_tag, _ = Tag.get_or_create(name="django")
art.tags.add(python_tag, django_tag) # insert the join rows
print([t.name for t in art.tags]) # ['python', 'django']
# remove an association
art.tags.remove(django_tag)
# the reverse direction: which articles carry a tag
art_list = python_tag.articles # a query, like the one-to-many backref
print(python_tag.articles.count())
# filter articles that have ALL of a set of tags (the hard query):
# an article must have a join row for each tag
q = (Article.select()
.join(Article.tags)
.where(Article.tags ** ("python", "django"))
.group_by(Article.id)
.having(Sql("COUNT(DISTINCT tag.id)") == 2))
Access is symmetric with the one-to-many: art.tags is the set of related tags (a query you can iterate), tag.articles is the reverse, and add/remove manage the join rows - add is an insert into the through table, remove a delete from it, both scoped to this instance. The last query is the case that distinguishes many-to-many: "articles tagged with BOTH python and django" is not a single IN - it joins the through table, filters to the tag set, groups by article, and keeps the groups whose distinct tag count equals the set size. That join-group-having shape is the standard SQL answer and the query to recognize when the requirement changes from "any of" to "all of."
The Through Model: When the Association Has Data
# when the join row carries its own fields, name the through model:
class Enrollment(Model):
student = ForeignKeyField("Student", backref=None)
course = ForeignKeyField("Course", backref=None)
enrolled_at = DateTimeField(default=datetime.now)
grade = CharField(null=True)
class Meta:
database = db
class Course(Model):
title = CharField()
students = ManyToManyField(
"Student",
through=Enrollment,
backref="courses",
)
class Meta:
database = db
# the through model is a normal model: it is queryable directly
e = Enrollment.get(Enrollment.student_id == 1, Enrollment.course_id == 5)
print(e.grade, e.enrolled_at)
# and the ManyToManyField still gives the simple add/remove + query access
The moment the association needs its own data - an enrollment has a date and a grade, an order line has a quantity - the anonymous join table is not enough, and the through argument names an explicit model for it. The through model declares the two FKs (with backref=None to suppress the automatic reverse managers, since the ManyToManyField owns those) plus its own fields, and it is a fully normal model: you query it directly for the association data (Enrollment.get(...)), while the ManyToManyField keeps providing the simple course.students query and add/remove. The rule: start with the plain ManyToManyField, and promote to a through model when a field that belongs to the association appears - the promotion is a schema change (the migrations chapter) but the query access is unchanged.
Deletion Across the Relationship
# deleting an article removes its join rows (the associations die with it)
art.delete_instance()
# the TAGS survive - deleting a row does not delete the related tags,
# only the join table rows that referenced the article
# deleting a tag: same, its join rows go, the articles survive
python_tag.delete_instance()
# to also delete the related rows on the OTHER side is not automatic -
# a tag is only deleted if nothing else references it, and with the
# default RESTRICT behavior a delete fails while join rows exist,
# so delete the associations (or use recursive) deliberately:
python_tag.delete_instance(recursive=True)
Deletion in a many-to-many is the join rows, not the related objects: deleting an article removes its article_tag rows, and the tags it pointed at are untouched - the association is a separate row with its own lifecycle. The reverse is symmetric. The on_delete on the through model's FKs (CASCADE by Peewee's default for the through table) is what removes the join rows when either side is deleted, and recursive=True is the explicit form when the default behavior has been changed. The mental model that makes all of it predictable: the through table is a real table with real rows, and deleting a parent deletes the rows that reference it - nothing more, nothing less, and the related objects on the other side are ordinary rows that survive unless something else deletes them.
Summary
A many-to-many is declared with a single ManyToManyField, which creates the join table with its two FKs and the unique pair constraint, and gives both directions of access - article.tags and the backref tag.articles, each a query, with add/remove managing the join rows. When the association carries its own data, promote to a through model (the two FKs plus the association fields) that is directly queryable while the field keeps the simple access. "All of a set of tags" is the join-group-having query, not an IN. Deletion removes the join rows, never the related objects - the through table is a real table, and its rows are what a delete touches. That structural fact - two one-to-many relationships around a middle table - is the lens for every many-to-many query the app will ever run.