Chapter 6: relationships. The feature that makes the ORM an ORM. We cover one-to-many, many-to-many, the loading strategies, association tables, and how to avoid N+1.
One-to-many
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)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped[User] = relationship(back_populates="posts")
back_populates keeps both sides in sync.
user.posts.append(post)
# post.author == user automatically
Many-to-many
from sqlalchemy import Table, Column
post_tags = 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):
tags: Mapped[list["Tag"]] = relationship(secondary=post_tags, back_populates="posts")
class Tag(Base):
posts: Mapped[list[Post]] = relationship(secondary=post_tags, back_populates="tags")
For many-to-many with extra columns (e.g., assigned_at): use an association object pattern (a real model with FKs to both sides).
One-to-one
class User(Base):
profile: Mapped["Profile | None"] = relationship(back_populates="user", uselist=False)
class Profile(Base):
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
user: Mapped[User] = relationship(back_populates="profile")
Unique FK on the child side; uselist=False on the parent.
Loading strategies
The N+1 problem:
users = (await session.execute(select(User))).scalars().all()
for u in users:
print(u.email, u.posts) # one query per user
If you have 100 users, that’s 101 queries. Catastrophic.
Solutions:
selectinload
from sqlalchemy.orm import selectinload
stmt = select(User).options(selectinload(User.posts))
users = (await session.execute(stmt)).scalars().all()
for u in users:
print(u.email, u.posts) # all preloaded
Two queries: SELECT users then SELECT posts WHERE author_id IN (...). Default-best for one-to-many.
joinedload
from sqlalchemy.orm import joinedload
stmt = select(User).options(joinedload(User.profile))
Joins in one query. Best for one-to-one or many-to-one (no row multiplication).
For one-to-many with joinedload, rows multiply (a user with 5 posts → 5 rows duplicated). SQLAlchemy de-duplicates but it’s wasteful.
subqueryload
from sqlalchemy.orm import subqueryload
stmt = select(User).options(subqueryload(User.posts))
Older alternative to selectinload. Selectinload is usually preferred.
lazy=‘dynamic’
class User(Base):
posts: Mapped["DynamicLoader[Post]"] = relationship(lazy="dynamic")
user.posts returns a query object, not a list. You then add filters / pagination and execute.
recent = await session.execute(user.posts.filter(Post.created_at > yesterday))
For one-to-many that can be huge.
In async, lazy="dynamic" has caveats. Often easier: explicit query.
Per-relationship default
class User(Base):
posts: Mapped[list[Post]] = relationship(lazy="selectin")
Default load when accessed. For very common access patterns. But: hidden cost on every load, even when you don’t need posts.
For most apps: explicit options(selectinload(...)) per query is clearer.
Nested loading
stmt = select(User).options(
selectinload(User.posts).selectinload(Post.comments)
)
Loads users, posts (via IN), and comments (via IN). Three queries; no N+1.
Filtering related
stmt = select(User).join(Post).where(Post.title.contains("python"))
Don’t confuse selectinload(...) (loads relationship) with join(...) (filters parent by child).
To filter loaded children, use a custom relationship or filter in Python (rarely):
from sqlalchemy.orm import selectinload, with_loader_criteria
stmt = select(User).options(
selectinload(User.posts),
with_loader_criteria(Post, Post.published == True),
)
with_loader_criteria filters the loaded children.
Backref vs back_populates
# back_populates (recommended; explicit)
class User: posts = relationship(back_populates="author")
class Post: author = relationship(back_populates="posts")
# backref (auto-creates the reverse; magic)
class User: posts = relationship("Post", backref="author")
# Post.author created automatically
Prefer back_populates. Explicit; type-checks better.
Cascade
class User(Base):
posts: Mapped[list[Post]] = relationship(back_populates="author", cascade="all, delete-orphan")
all, delete-orphan means: when a Post is removed from user.posts, delete it.
Common cascades:
all= save-update + merge + refresh-expire + expunge + delete.delete-orphan= delete when removed from parent.save-update= default; cascades adds.
For most apps: all, delete-orphan for owned children; default otherwise.
Cascade vs FK ondelete
ORM cascade (Python-side) is different from ON DELETE CASCADE (DB-side). They can complement:
author_id = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) # DB-side
posts = relationship(cascade="all, delete-orphan", passive_deletes=True)
passive_deletes=True tells the ORM not to load and individually delete posts; rely on the DB cascade.
Polymorphism (single-table inheritance)
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True)
type: Mapped[str]
name: Mapped[str]
__mapper_args__ = {"polymorphic_on": "type", "polymorphic_identity": "item"}
class Book(Item):
isbn: Mapped[str | None]
__mapper_args__ = {"polymorphic_identity": "book"}
class CD(Item):
artist: Mapped[str | None]
__mapper_args__ = {"polymorphic_identity": "cd"}
One table; type column distinguishes. select(Book) filters automatically.
Joined-table and concrete inheritance also exist; rarely worth it.
Self-referential
class Category(Base):
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"))
parent: Mapped["Category | None"] = relationship(remote_side="Category.id", back_populates="children")
children: Mapped[list["Category"]] = relationship(back_populates="parent")
For trees. Use recursive CTEs for traversal.
Relationship arguments to know
back_populates: explicit two-way.cascade: which ops propagate.passive_deletes: rely on DB cascade.lazy: default load strategy.order_by: order children.viewonly=True: read-only relationship; not part of unit-of-work.secondary: association table for many-to-many.primaryjoin/secondaryjoin: explicit join conditions for non-trivial cases.
Eager loading defaults
Setting lazy="selectin" on a relationship loads it eagerly always. For per-query control, use options(selectinload(...)).
For consistent read paths, eager-default can simplify code. Watch for over-fetching.
Common mistakes
1. N+1
The classic. Fix with selectinload.
2. joinedload for one-to-many
Row multiplication. Use selectinload for collections.
3. Missing back_populates
Updates to one side don’t reflect on the other in memory.
4. Forgetting cascade
Delete user; orphan posts in DB. Add cascade or DB-level FK ON DELETE.
5. lazy=‘dynamic’ in async
Some operations don’t work directly; usually clearer to write explicit queries.
Performance patterns
For a list endpoint with relationships:
async def list_users_with_posts(session: AsyncSession):
stmt = (
select(User)
.options(selectinload(User.posts).load_only(Post.id, Post.title))
.limit(20)
)
return (await session.execute(stmt)).scalars().all()
load_only restricts loaded columns on the related table. Smaller payload.
For listing posts with author info:
stmt = select(Post).options(joinedload(Post.author))
joinedload for many-to-one (one author per post; no multiplication).
What’s next
Chapter 7: Transactions and concurrency.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .