Relationships
relationship() declares Python-side links between mapped classes. Under the hood it’s a JOIN or a second SELECT (depending on loading strategy). It does NOT create the foreign key — you still need ForeignKey(...) on the column.
The four shapes
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")
The FK lives on the “many” side (posts.author_id). User.posts is the collection; Post.author is the scalar reference.
Many-to-one
The same setup viewed from the other side. Post.author is many-to-one.
One-to-one
Same as one-to-many but with uselist=False:
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
profile: Mapped["Profile"] = relationship(back_populates="user", uselist=False)
class Profile(Base):
__tablename__ = "profiles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
user: Mapped["User"] = relationship(back_populates="profile")
unique=True on user_id enforces the “one” at the DB level. Without it the schema allows many-to-one even if Python expects one-to-one.
Many-to-many
Requires an association table:
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):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
tags: Mapped[list["Tag"]] = relationship(secondary=post_tags, back_populates="posts")
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
posts: Mapped[list["Post"]] = relationship(secondary=post_tags, back_populates="tags")
The composite primary key on the association table prevents duplicate (post, tag) pairs. SQLAlchemy auto-manages inserts/deletes when you post.tags.append(tag) / post.tags.remove(tag).
Association object (M:N with extra columns)
When the link itself has data (created_at, role, weight), use an explicit class instead of a Table:
class PostTag(Base):
__tablename__ = "post_tags"
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), primary_key=True)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
post: Mapped["Post"] = relationship(back_populates="post_tags")
tag: Mapped["Tag"] = relationship(back_populates="post_tags")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
post_tags: Mapped[list["PostTag"]] = relationship(back_populates="post")
Now post.post_tags[0].created_at works. The trade-off: you manipulate PostTag instances rather than tag objects directly. For ergonomics, layer association_proxy:
from sqlalchemy.ext.associationproxy import association_proxy
class Post(Base):
post_tags: Mapped[list["PostTag"]] = relationship(back_populates="post")
tags: AssociationProxy[list["Tag"]] = association_proxy("post_tags", "tag")
Now post.tags lets you read/write Tag instances directly; the proxy maintains PostTag rows behind the scenes.
back_populates vs backref
# back_populates — both sides declared (explicit; recommended)
class User(Base):
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
author: Mapped["User"] = relationship(back_populates="posts")
# backref — only one side declared (the other auto-generated)
class User(Base):
posts: Mapped[list["Post"]] = relationship(backref="author")
class Post(Base):
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
# `author` attribute auto-created on Post
backref is concise but less type-checkable. back_populates (each side explicit) is the recommended 2.0 style.
What relationship() does at access time
By default, lazy loading. The first time you touch user.posts, SQLAlchemy issues:
SELECT * FROM posts WHERE author_id = :user_id
Subsequent accesses use the loaded collection. Loading strategies (joined, selectin, etc.) change this — see 06_loading_strategies_n_plus_1.md.
The relationship attribute returns:
- The collection (for one-to-many / many-to-many) — usually a list.
- The related object or
None(for many-to-one / one-to-one). - A
Query-like object iflazy="dynamic"(legacy; produces a query you can filter further).
Cascades
What happens to related objects when the parent is added, deleted, or removed.
class User(Base):
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
cascade="all, delete-orphan",
)
Common cascade values:
| Value | Effect |
|---|---|
save-update |
adding a child auto-adds it to the session if the parent is in the session (default) |
delete |
deleting the parent also deletes children |
delete-orphan |
a child detached from its parent is deleted |
merge |
session.merge(parent) cascades to children |
refresh-expire |
refresh/expire cascades |
all |
shorthand for save-update, merge, refresh-expire, delete |
"all, delete-orphan" |
all plus delete-orphan — common for “owned” children |
The most common cascade for “parent owns child” relationships:
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
cascade="all, delete-orphan",
)
Now session.delete(user) deletes all their posts; user.posts.remove(post) deletes that post.
Cascade is SQLAlchemy-side, distinct from DB ON DELETE CASCADE. Both can be set; they’re not redundant — Python cascade handles loaded session state, DB cascade handles raw SQL deletes. For “all parent.children get deleted when parent is deleted,” set both:
author_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
# and on the parent's relationship:
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
cascade="all, delete-orphan",
passive_deletes=True, # let the DB handle deletion; don't issue per-row DELETE from Python
)
passive_deletes=True is important — without it, SQLAlchemy issues a DELETE for every loaded child even though the DB will cascade. Big perf win for many children.
Self-referential relationships
For trees / hierarchies:
class Category(Base):
__tablename__ = "categories"
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")
remote_side tells SQLAlchemy which side of the relationship is “remote” (the FK target). Required for self-references; without it SQLAlchemy can’t figure out which way is up.
Complex join conditions
Sometimes the relationship isn’t a simple a.id = b.a_id:
class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
# only "published" posts, not drafts
published_posts: Mapped[list["Post"]] = relationship(
primaryjoin="and_(User.id == Post.author_id, Post.status == 'published')",
viewonly=True,
)
primaryjoin overrides the inferred join. viewonly=True is critical here: SQLAlchemy can’t auto-maintain a filtered relationship through cascades, so make it read-only.
viewonly=True
Tells SQLAlchemy “don’t try to manage this relationship; it’s purely for reads.” Useful for:
- Filtered relationships (published_posts above).
- Relationships across complex expressions.
- Derived “view” attributes.
viewonly=True skips all writes/cascades. Mutating the collection has no effect on the DB.
Common pitfalls
- N+1 queries from accessing
parent.childrenin a loop. The default lazy loading is fine for one record, terrible for lists. See 06_loading_strategies_n_plus_1.md. - Forgetting
back_populateson both sides. SQLAlchemy can’t coordinate Python-side state if only one side knows about the relationship. - DB
ON DELETE CASCADEwithoutpassive_deletes=True. SQLAlchemy issues redundant per-row DELETEs. cascade="all"includesdeletebut NOTdelete-orphan. Need to adddelete-orphanexplicitly when “removing from collection = delete the row.”- Accessing
user.postsafter the session is closed. Lazy load triggers a query on a detached instance →DetachedInstanceError. See 14_common_pitfalls.md. - Self-referential without
remote_side. Cryptic error: “Could not determine direction for relationship.”
Common interview confusions
- “
relationship()creates a foreign key.” — no, it creates a Python-side accessor. TheForeignKey(...)on the column creates the DB-level FK. - “
backrefis the modern way.” —back_populatesis the modern recommendation (more explicit, better type checking).backrefis the older shortcut. - “
cascade='all'deletes children.” —allis shorthand forsave-update, merge, refresh-expire, delete. To auto-delete orphaned children (removed from collection), adddelete-orphan.
Interview angle
- “How do you set up a one-to-many in SQLAlchemy 2.0?” —
ForeignKeyon the child’s column,relationship(back_populates=...)on both sides. Parent declaresMapped[list["Child"]]; child declaresMapped["Parent"]. - “
back_populatesvsbackref?” —back_populatesis explicit on both sides (recommended).backrefdeclares both from one side (concise but less type-checkable). - “What’s a cascade and when does
delete-orphanmatter?” — controls how operations on the parent propagate to children.delete-orphanmeans a child removed from the parent’s collection (or whose parent is unset) is deleted from the DB. Use for “owned” children. - “DB-level
ON DELETE CASCADEvs SQLAlchemy cascade — both?” — both, withpassive_deletes=Trueon the relationship to avoid redundant per-row DELETE from Python. DB cascade handles raw SQL; SQLAlchemy cascade handles loaded session state. - “How do you model many-to-many with extra columns on the link?” — define the association as a full mapped class (with PK across both FKs and the extra columns); use
association_proxyfor ergonomic access if needed. - “What does
viewonly=Truedo?” — makes the relationship read-only; SQLAlchemy won’t try to maintain it through cascades or writes. Required for filtered/computed relationships.