Cheatsheet for relationships. Long-form: Textbook Ch 6 .
One-to-many
from sqlalchemy.orm import relationship
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
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)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
author: Mapped[User] = relationship(back_populates="posts")
back_populates (recommended over backref).
Many-to-many
from sqlalchemy import Table, Column
post_tags = Table(
"post_tags",
Base.metadata,
Column("post_id", ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), 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 M:N with extra columns: use an association-object pattern (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", ondelete="CASCADE"), unique=True)
user: Mapped[User] = relationship(back_populates="profile")
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")
Cascade
relationship(cascade="all, delete-orphan")
# Common values: save-update, merge, refresh-expire, expunge, delete, delete-orphan, all
For “child deleted when parent is”: delete-orphan.
With DB-level ON DELETE CASCADE: add passive_deletes=True to avoid Python-side per-row deletes.
Loading strategies (N+1 fix)
from sqlalchemy.orm import selectinload, joinedload, subqueryload, contains_eager, raiseload
# selectinload — best for one-to-many
stmt = select(User).options(selectinload(User.posts))
# joinedload — best for one-to-one / many-to-one
stmt = select(Post).options(joinedload(Post.author))
# Nested
stmt = select(User).options(
selectinload(User.posts).selectinload(Post.comments)
)
# load_only restricts columns on loaded relationship
stmt = select(User).options(
selectinload(User.posts).load_only(Post.id, Post.title)
)
# raiseload to catch N+1 in dev/tests
stmt = select(User).options(raiseload("*"))
Per-relationship default load strategy
class User(Base):
posts: Mapped[list[Post]] = relationship(lazy="selectin") # always eager-load
profile: Mapped[Profile] = relationship(lazy="joined")
tags: Mapped[list[Tag]] = relationship(lazy="raise") # forbid lazy
For most apps: explicit options(selectinload(...)) per query is clearer.
Filtering loaded children
from sqlalchemy.orm import with_loader_criteria
stmt = select(User).options(
selectinload(User.posts),
with_loader_criteria(Post, Post.published == True),
)
viewonly relationship
class User(Base):
recent_posts: Mapped[list[Post]] = relationship(
primaryjoin="and_(Post.author_id == User.id, Post.created_at > func.now() - text(\"'7 days'::interval\"))",
viewonly=True,
)
Read-only; not part of unit-of-work.
Polymorphism (single-table)
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"}
Async-loading caveats
In async, default lazy-load won’t auto-await:
# Option 1: explicit selectinload upfront
stmt = select(User).options(selectinload(User.posts))
# Option 2: awaitable_attrs
posts = await user.awaitable_attrs.posts
# Option 3: lazy="raise" in dev to force explicitness
Common mistakes
- N+1 (the classic): fix with
selectinload. joinedloadfor one-to-many: row multiplication; useselectinload.- Missing
back_populates: in-memory desync. - Cascade without DB-level FK ON DELETE: orphans in DB.
- Lazy-load in async without
awaitable_attrs.
Read this next
If you want my repository pattern with eager-loading discipline, it’s at rajpoot.dev .
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 .