One-to-Many Relationships
The most common relationship in any data model is one-to-many: one author has many articles, one user has many orders. In Peewee it is a single ForeignKeyField on the "many" side pointing at the "one" side, and that one declaration produces three capabilities: the foreign-key column in the table, the instance attribute that crosses the relationship (article.author), and the reverse manager that lists the children (author.articles). This chapter builds the author-article relationship, uses all three capabilities, and covers the referential-integrity options that decide what happens when a parent row is deleted.
Declaring the ForeignKey
from peewee import *
class Author(Model):
name = CharField()
email = CharField(unique=True)
class Meta:
database = db
class Article(Model):
title = CharField()
body = TextField()
# the FK lives on the MANY side, pointing at the ONE side
author = ForeignKeyField(Author, backref="articles")
published = BooleanField(default=False)
# the table Peewee builds for Article includes:
# author_id INTEGER NOT NULL REFERENCES authors (id)
# backref="articles" creates the REVERSE manager on Author
The ForeignKeyField(Author) on Article does the three things at once. The schema: the article table gains an author_id column with a REFERENCES constraint. The forward attribute: an article instance has article.author, which is an Author instance (loaded on first access). The reverse manager: the backref="articles" argument creates author.articles, a query object listing the author's articles. The naming convention: the field is named for the parent (author), and the backref is the plural reverse (articles). The backref name must not collide with a field name on the parent - articles works because Author has no field by that name.
Using the Relationship: Both Directions
# forward: an article knows its author
a = Article.get(Article.id == 1)
print(a.author.name) # the Author instance, one query to load
# reverse: an author lists their articles (a query, not a list)
ada = Author.get(Author.email == "[email protected]")
q = ada.articles # a SELECT query over article
published = ada.articles.where(Article.published == True)
count = ada.articles.count()
for art in published.execute():
print(art.title)
# creating across the relationship: pass the instance or the id
art = Article.create(title="New Post", body="...", author=ada)
art2 = Article.create(title="Another", body="...", author=ada.id)
# the FK column is directly accessible when you need it
print(art.author_id) # the raw id, for joins and filters
Both directions are one attribute away. Forward, article.author resolves the FK to the parent instance. Reverse, the backref (ada.articles) is not a list - it is a Select query pre-filtered to this author, which is exactly why you can chain .where(), .count(), and .execute() on it. Creation accepts either the parent instance or its raw id, and the FK column itself (art.author_id) is accessible when a join or a filter wants the id rather than the object. The laziness is the operational detail: each article.author access on a fresh row is a query, so a loop over 50 articles naively reading .author issues 50 extra queries - the joins chapter's .join() and the prefetch tool collapse that into one.
Nullable Parents and Defaults
class Comment(Model):
text = TextField()
# a comment may exist before an author is assigned:
author = ForeignKeyField(Author, null=True, backref="comments")
article = ForeignKeyField(Article, backref="comments")
# with null=True, the FK column accepts NULL:
c = Comment.create(text="orphan note", article=Article.get(Article.id == 1))
print(c.author) # None - no parent yet
c.author = Author.get(Author.id == 2)
c.save() # now it points at an author
By default a ForeignKeyField is not-null - every child must have a parent, which is the right default for the author-article case (an article without an author is usually a data error). The null=True argument relaxes it for the genuinely-optional parent (a comment whose author is not set yet), and the attribute then reads None until a parent is assigned. The decision is the same as the nullability decision on plain fields from the models chapter: nullable is explicit, and the not-null default is the safer schema. A not-null FK also enforces referential integrity at the database level - you cannot create an article pointing at a nonexistent author id, which is the constraint doing its job.
Delete Behavior: The on_delete Options
class Article(Model):
title = CharField()
# what happens to the articles when the author is deleted:
author = ForeignKeyField(
Author,
backref="articles",
on_delete=CASCADE, # delete the articles too
# the other options:
# RESTRICT - cannot delete the author while articles exist
# SET_NULL - set article.author_id to NULL (needs null=True)
# NO_ACTION - the database default (often behaves like RESTRICT)
)
# delete_instance(recursive=True) is the ORM-level cascade:
ada = Author.get(Author.email == "[email protected]")
ada.delete_instance(recursive=True) # articles go first, then the author
Two layers answer "what happens to the children when the parent is deleted." The schema level is the on_delete argument on the FK: CASCADE (the database deletes the children automatically), RESTRICT (the delete fails while children exist - the safe default), SET_NULL (the children's FK becomes NULL, requiring null=True), and NO_ACTION. The ORM level is delete_instance(recursive=True), which walks the backrefs and deletes the related rows itself, in the right order. The production choice is usually RESTRICT in the schema (so a mistaken delete is an error, not data loss) plus recursive=True in the code (so the deliberate delete does the work). Changing on_delete after the table exists is a schema change - the migrations chapter's territory.
Summary
One-to-many is a single ForeignKeyField on the many side, and that declaration produces the FK column with its REFERENCES constraint, the forward attribute (article.author), and the reverse manager (backref="articles", a query object you can filter, count, and execute). Create across the relationship with the parent instance or its id, use null=True only for a genuinely-optional parent, and decide the delete behavior at both layers - on_delete in the schema (RESTRICT as the safe default, CASCADE or SET_NULL when the children must follow) and delete_instance(recursive=True) in the code for the deliberate cascade. Remember the lazy cost - each forward access is a query - and the joins chapter's tools when a list of children needs their parents in one round trip.