12.9. Relationships
Table of Contents
Understanding Relationships with an ORM
When you connect your backend to a relational database, you rarely work with isolated tables. Users have posts, orders have items, products belong to categories. In SQL you represent these links with foreign keys. In an ORM you represent them with relationships between models.
This chapter focuses on how to model and use relationships in an ORM, using SQLAlchemy-style examples in Python. We will not re-explain what primary keys and foreign keys are, since that is covered in database chapters. Here the focus is how to express and work with them from your code.
Throughout, assume a typical SQLAlchemy 2.x style setup with:
from sqlalchemy.orm import DeclarativeBase, relationship, Mapped, mapped_column
from sqlalchemy import ForeignKey, String, Integer
class Base(DeclarativeBase):
passWhy Relationships Matter in an ORM
Without relationships, your ORM models are just separate tables that you must manually glue together with raw queries. Relationships help you:
- Navigate from one object to related objects, for example
user.posts. - Let the ORM generate correct
JOINqueries automatically. - Control how related data is loaded (lazy or eager loading).
- Keep consistency between objects in memory and in the database.
A relationship in ORM has two parts:
- A foreign key column on the child or linking table.
- A relationship attribute that maps Python objects to each other.
Key rule:
Every ORM relationship depends on a correct foreign key in the database.
If the foreign key is wrong, the relationship will not work correctly.
A simple mental model:
- Foreign key: "How are rows connected in the database?"
- Relationship: "How are objects connected in Python code?"
One-to-One Relationships
A one-to-one relationship means that one row in table A is linked to at most one row in table B, and vice versa.
Common examples:
- User and user profile.
- Country and capital city.
- Order and invoice (if there is only one invoice per order).
Basic One-to-One Example
Imagine each User has exactly one UserProfile.
Database side
userstable withidas primary key.user_profilestable with:idprimary key.user_idforeign key tousers.id.user_idalso has a unique constraint to enforce "only one profile per user".
ORM models
from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship, Mapped, mapped_column
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True)
# One-to-one: User -> UserProfile
profile: Mapped["UserProfile"] = relationship(
back_populates="user",
uselist=False,
)
class UserProfile(Base):
__tablename__ = "user_profiles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"),
unique=True # ensures one-to-one at DB level
)
full_name: Mapped[str] = mapped_column(String)
# Back reference to User
user: Mapped[User] = relationship(back_populates="profile")Important details:
uselist=FalseonUser.profiletells SQLAlchemy:- "This relationship returns a single object, not a list."
unique=Trueonuser_idensures database-level one-to-one.back_populatesconnects both ends of the relationship.
Using a One-to-One Relationship
# create a user and its profile
user = User(email="alice@example.com")
profile = UserProfile(full_name="Alice Example")
user.profile = profile
session.add(user)
session.commit()
# later, load and use
db_user = session.get(User, user.id)
print(db_user.profile.full_name) # "Alice Example"
# you can also access the user from the profile
db_profile = session.get(UserProfile, profile.id)
print(db_profile.user.email) # "alice@example.com"
Key idea: you work with Python object attributes, not foreign key integers directly. The ORM handles user_id behind the scenes.
When Should You Use One-to-One?
Use a one-to-one relationship when:
- You logically have a single record per parent.
- The extra fields are "optional" or "rare" and you want to put them in a separate table.
- You want to keep the main table smaller and more focused.
If you are not sure, starting with a one-to-many is often safer, then later enforce uniqueness if necessary.
One-to-Many Relationships
One-to-many is the most common relationship type. It means one row in table A can be linked to many rows in table B, while each row in B belongs to exactly one row in A.
Examples:
- User has many posts.
- Category has many products.
- Order has many order items.
Basic One-to-Many Example
Let's model users and their blog posts.
Database side
userstable withidprimary key.poststable with:idprimary key.user_idforeign key tousers.id.
ORM models
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True)
# One-to-many: User -> Post
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
cascade="all, delete-orphan",
)
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
title: Mapped[str] = mapped_column(String)
content: Mapped[str] = mapped_column(String)
# Many-to-one: Post -> User
author: Mapped[User] = relationship(back_populates="posts")Notes:
User.postsis a list ofPostobjects.Post.authoris a singleUserobject.- The
cascadeargument tells SQLAlchemy what happens when you delete a user: "all, delete-orphan"means posts that belong only to that user will be deleted too.
Important rule:
In a one-to-many relationship, the "many" side holds the foreign key.
The ORM relationship exists on both sides, but the database foreign key is only on one table.
Working with One-to-Many
Creating related objects
# create a user
user = User(email="bob@example.com")
# add posts through the relationship
user.posts.append(Post(title="First post", content="Hello"))
user.posts.append(Post(title="Second post", content="More text"))
session.add(user)
session.commit()
print(user.posts[0].title) # "First post"You can also assign the user explicitly on the post:
post = Post(
title="Third post",
content="Another one",
author=user, # sets user_id automatically
)
session.add(post)
session.commit()Querying and navigating
# fetch a user and see their posts
db_user = session.query(User).filter_by(email="bob@example.com").one()
for post in db_user.posts:
print(post.title)
# fetch a post and see its author
db_post = session.query(Post).first()
print(db_post.author.email)The ORM will issue the necessary SQL to fetch the right records and set up the object graph.
Controlling Cascades
Cascade rules are important to avoid orphaned or unexpected data. Common options:
| Cascade option | Meaning (simplified) |
|---|---|
save-update | Propagate changes to child objects |
delete | Delete children when parent is deleted |
delete-orphan | Delete children that are no longer attached to parent |
all | Includes most operations (save-update, merge, delete) |
Practical combinations:
# Typical "delete children when parent is deleted"
relationship(..., cascade="all, delete-orphan")
# No cascade delete, children must be deleted separately
relationship(..., cascade="save-update, merge")For example, you might want to:
- Delete order items when an order is deleted.
- Not delete comments when a user account is deleted, to preserve content history.
Many-to-Many Relationships
Many-to-many means each row in table A can be linked to many rows in table B, and each row in B can be linked to many rows in A.
Examples:
- Users and roles.
- Products and tags.
- Students and courses.
In a relational database, many-to-many is implemented with a link table (also called association table or join table).
Basic Many-to-Many Example
Suppose:
- A
Postcan have multipleTagobjects. - A
Tagcan be used by multiplePostobjects.
Database side
poststable withid.tagstable withidandname.post_tagstable with:post_idforeign key toposts.id.tag_idforeign key totags.id.- Composite primary key on
(post_id, tag_id).
ORM models with an association table
from sqlalchemy import Table, Column
post_tag_table = Table(
"post_tags",
Base.metadata,
Column("post_id", ForeignKey("posts.id"), primary_key=True),
Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
tags: Mapped[list["Tag"]] = relationship(
secondary=post_tag_table,
back_populates="posts",
)
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True)
posts: Mapped[list[Post]] = relationship(
secondary=post_tag_table,
back_populates="tags",
)Key pieces:
post_tag_tableis a plainTableobject, not a mapped class.secondary=post_tag_tabletells SQLAlchemy which table to use for the many-to-many link.- Both sides use
back_populatesand reference the samesecondary.
Rule for many-to-many:
You always need a separate association table.
Do not try to store arrays or comma separated IDs in a single column instead.
Using a Many-to-Many Relationship
Creating and linking
post = Post(title="ORM relationships")
tag_sql = Tag(name="sql")
tag_python = Tag(name="python")
# Link tags to post
post.tags.append(tag_sql)
post.tags.append(tag_python)
session.add(post)
session.commit()
# Behind the scenes, post_tags rows are inserted:
# (post_id=post.id, tag_id=tag_sql.id)
# (post_id=post.id, tag_id=tag_python.id)You can also add posts to tags:
another_post = Post(title="Another post")
tag_python.posts.append(another_post)
session.add(another_post)
session.commit()Querying
Find all posts with a given tag:
python_tag = session.query(Tag).filter_by(name="python").one()
for post in python_tag.posts:
print(post.title)Find all tags for a given post:
some_post = session.query(Post).first()
for tag in some_post.tags:
print(tag.name)
The ORM generates a JOIN between posts, post_tags, and tags as needed.
Association Object Pattern
Sometimes the link itself has extra data. For example:
- Students and courses with a
gradein the middle. - Users and projects with a
rolein the project.
In that case, you map the association table as a full model class.
Example: students, courses, and enrollments with a grade.
class Enrollment(Base):
__tablename__ = "enrollments"
student_id: Mapped[int] = mapped_column(
ForeignKey("students.id"), primary_key=True
)
course_id: Mapped[int] = mapped_column(
ForeignKey("courses.id"), primary_key=True
)
grade: Mapped[str] = mapped_column(String)
student: Mapped["Student"] = relationship(back_populates="enrollments")
course: Mapped["Course"] = relationship(back_populates="enrollments")
class Student(Base):
__tablename__ = "students"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String)
enrollments: Mapped[list[Enrollment]] = relationship(
back_populates="student"
)
courses: Mapped[list["Course"]] = relationship(
secondary="enrollments",
back_populates="students",
viewonly=True, # derived from enrollments
)
class Course(Base):
__tablename__ = "courses"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
enrollments: Mapped[list[Enrollment]] = relationship(
back_populates="course"
)
students: Mapped[list[Student]] = relationship(
secondary="enrollments",
back_populates="courses",
viewonly=True,
)
Now you can access enrollment.grade and still navigate between students and courses.
Relationship Loading Strategies
Relationships control how objects are connected. Loading strategies control when related data is loaded from the database.
There are two main strategies:
- Lazy loading: Load related data only when you first access it.
- Eager loading: Load related data upfront, together with the parent.
Lazy Loading
This is the default in most ORMs. Example:
user = session.query(User).first()
# At this point, no posts have been loaded yet.
print(user.posts) # SQL query is executed herePros:
- Less data is loaded if you never access the relationship.
- Simple to reason about.
Cons:
- Can trigger the N+1 query problem if you loop over many parents and access children for each:
users = session.query(User).all() # 1 query
for user in users:
print(len(user.posts)) # 1 query per user, can be many!Eager Loading
Eager loading fetches related data in the same database round trip or with a limited number of queries.
Common SQLAlchemy options:
selectinloadjoinedload
Example: load users and their posts in one go.
from sqlalchemy.orm import selectinload
users = (
session.query(User)
.options(selectinload(User.posts))
.all()
)
for user in users:
print(user.email, len(user.posts)) # no extra queries
selectinload pattern:
- First query to load all users.
- Second query to load all posts for those users, using
IN (user_ids).
joinedload pattern:
from sqlalchemy.orm import joinedload
users = (
session.query(User)
.options(joinedload(User.posts))
.all()
)
joinedload uses a LEFT OUTER JOIN to load users and posts together.
Performance rule:
Use lazy loading for simple, one off access.
Use eager loading when you know you will access relationships in bulk to avoid the N+1 query problem.
Setting Loading Strategy in the Relationship
You can also set default loading on the relationship itself:
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
lazy="selectin", # or "joined", "select", "raise", etc.
)This way, every query benefiting from that relationship uses the same strategy unless overridden.
Bidirectional Relationships and `back_populates` vs `backref`
Relationships can be:
- Unidirectional: Only one side knows about the other, for example
Post.author, butUserhas nopostsattribute. - Bidirectional: Both sides know about each other, for example
User.postsandPost.author.
Bidirectional relationships are very common and convenient.
`back_populates`
back_populates is explicit. You define both relationship attributes and tell them which one is the other side.
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped[User] = relationship(back_populates="posts")Advantages:
- Clear and explicit.
- Works well with type hints and tools like mypy.
- Easier to read later.
`backref`
backref is shorthand. You define a relationship on one side and ORM automatically creates the reverse side.
from sqlalchemy.orm import backref
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
posts: Mapped[list["Post"]] = relationship(
backref=backref("author") # creates Post.author automatically
)
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
This is shorter, but less explicit. In new projects, prefer back_populates since it plays better with modern type annotations.
Consistency rule:
Choose either back_populates or backref as your standard and use it consistently across your codebase.
Relationship Configuration Tips and Common Pitfalls
Relationships look simple, but a few small mistakes can cause confusing bugs. Here are some practical tips.
1. Always Align Foreign Keys and Relationships
If your relationship is Post.author, the foreign key must match:
- Foreign key:
posts.user_id -> users.id. - Relationship:
author = relationship("User", back_populates="posts").
Common error: misspelling table or column name in ForeignKey("users.id"). This breaks the link even if code compiles.
Check:
- Table name in
ForeignKeyis the same as__tablename__. - Column name exists and is a primary key or has an index when appropriate.
2. Decide Ownership and Cascades
Ask, for each relationship:
- Who owns whom?
- What should happen to children when parent is deleted?
Examples:
| Relationship | Ownership decision | Cascade |
|---|---|---|
| User β UserProfile | User owns profile, delete profile with user | all, delete-orphan |
| User β Post | Depends on product, often delete posts or not | Configurable |
| Order β OrderItem | Order owns items, delete items with order | all, delete-orphan |
| Product β Tag | Neither owns the other, only delete association | No delete cascade |
If you are unsure, start with no delete cascade and handle deletions in code until the behavior is clear.
3. Be Careful with Lazy Loading in APIs
In a web API, a common pattern is:
- Fetch a list of objects.
- Serialize them to JSON.
- Return them in a response.
If serialization code accesses relationships, and those are lazy loaded, you can accidentally trigger N+1 queries.
Consider:
users = session.query(User).all()
result = []
for user in users:
result.append({
"id": user.id,
"email": user.email,
"posts": [p.title for p in user.posts], # triggers query per user
})Better:
from sqlalchemy.orm import selectinload
users = (
session.query(User)
.options(selectinload(User.posts))
.all()
)Then serialize, and no extra queries are created inside the loop.
4. Use Relationship Properties Instead of Foreign Keys in Code
Avoid code like:
post = Post(user_id=user.id, title="Hi")Prefer:
post = Post(author=user, title="Hi")Benefits:
- ORM sets the
user_idcorrectly. - If you change the primary key field or logic, less code breaks.
- Code is easier to read, because
author=useris more meaningful thanuser_id=user.id.
5. Bidirectional Consistency
When you modify a relationship, the ORM keeps both sides in sync.
user = User(email="c@example.com")
post = Post(title="Hello")
post.author = user
# Now user.posts contains post automatically
user.posts.remove(post)
# Now post.author is NoneIf you see inconsistencies between the two sides, something is wrong with how the relationship is configured.
6. Use Type Hints to Clarify Relationships
In SQLAlchemy 2.x you can use generics with Mapped and standard Python types:
from typing import List
posts: Mapped[List["Post"]]
author: Mapped["User"]This makes it clear which relationships return lists and which return single objects, and helps static analysis tools catch mistakes.
Summary
In this chapter you learned how to represent database relationships in an ORM:
- One-to-one connects a single row on each side, often with
uselist=Falseand a unique foreign key. - One-to-many connects one parent to many children, with the foreign key on the "many" side and a list of children on the parent.
- Many-to-many uses an association table and
secondaryin the relationship. - Loading strategies, such as lazy loading and eager loading, control when related data is fetched and can affect performance.
- Bidirectional relationships use
back_populatesorbackrefto keep both sides in sync. - Good configuration of foreign keys, cascade rules, and loading strategies prevents common bugs and performance issues.
Next chapters will build on this foundation and show how to perform queries, transactions, and more advanced patterns using these relationships.
Views: 7
KAHIBARO