Skillversitas

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.