ORM Relationships — Deep Dive
Relationship fundamentals in SQLAlchemy 2.0
SQLAlchemy 2.0 uses Mapped[] type annotations and mapped_column() for modern, type-safe relationship definitions.
One-to-Many
from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
The Mapped[list["Book"]] type annotation tells both SQLAlchemy and type checkers that author.books returns a list of Book objects. The Mapped["Author"] on the other side returns a single object.
One-to-One
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)
bio: Mapped[str | None] = mapped_column(Text)
user: Mapped["User"] = relationship(back_populates="profile")
The unique=True on user_id enforces the one-to-one constraint at the database level. uselist=False tells SQLAlchemy to return a single object instead of a list.
Many-to-Many with association table
from sqlalchemy import Table, Column, ForeignKey
# Association table — no ORM model needed for simple cases
student_course = Table(
"student_course",
Base.metadata,
Column("student_id", ForeignKey("students.id"), primary_key=True),
Column("course_id", ForeignKey("courses.id"), primary_key=True),
)
class Student(Base):
__tablename__ = "students"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
courses: Mapped[list["Course"]] = relationship(
secondary=student_course, back_populates="students"
)
class Course(Base):
__tablename__ = "courses"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
students: Mapped[list["Student"]] = relationship(
secondary=student_course, back_populates="courses"
)
Advanced relationship patterns
Association Object (many-to-many with extra data)
When the relationship itself carries data — enrollment date, grade, role — use a full ORM model instead of a bare table:
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
)
enrolled_at: Mapped[datetime] = mapped_column(default=func.now())
grade: Mapped[str | None] = mapped_column(String(2))
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)
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
class Course(Base):
__tablename__ = "courses"
id: Mapped[int] = mapped_column(primary_key=True)
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="course")
Now you access student.enrollments[0].grade and student.enrollments[0].course.title.
Association Proxy for cleaner access
The association object pattern requires navigating through the intermediate model. association_proxy shortcuts this:
from sqlalchemy.ext.associationproxy import association_proxy
class Student(Base):
__tablename__ = "students"
id: Mapped[int] = mapped_column(primary_key=True)
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
# Access courses directly: student.courses
courses = association_proxy("enrollments", "course")
Self-referential relationships
For tree structures like categories, org charts, or comment threads:
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"))
children: Mapped[list["Category"]] = relationship(
back_populates="parent",
cascade="all, delete-orphan",
)
parent: Mapped["Category | None"] = relationship(
back_populates="children",
remote_side=[id],
)
remote_side=[id] tells SQLAlchemy which side of the self-join is the “one” side.
Self-referential many-to-many (social follows)
follows = Table(
"follows",
Base.metadata,
Column("follower_id", ForeignKey("users.id"), primary_key=True),
Column("followed_id", ForeignKey("users.id"), primary_key=True),
)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
following: Mapped[list["User"]] = relationship(
secondary=follows,
primaryjoin=id == follows.c.follower_id,
secondaryjoin=id == follows.c.followed_id,
backref="followers",
)
Loading strategies
The choice of loading strategy has a massive impact on query count and performance.
Lazy loading (default)
# Executes 1 query
authors = session.scalars(select(Author)).all()
# Each access triggers another query — N+1 problem
for author in authors:
print(author.books) # 1 query per author
Eager loading with joinedload
from sqlalchemy.orm import joinedload
# 1 query with LEFT JOIN
stmt = select(Author).options(joinedload(Author.books))
authors = session.scalars(stmt).unique().all()
joinedload appends a JOIN to the original query. Good for one-to-one and one-to-many with small result sets. Can produce large cartesian products with multiple joinedload calls.
Subquery loading
from sqlalchemy.orm import subqueryload
# 2 queries: one for authors, one for all their books
stmt = select(Author).options(subqueryload(Author.books))
authors = session.scalars(stmt).all()
Better than joinedload for large collections — avoids duplicate parent rows in the result set.
Select-in loading
from sqlalchemy.orm import selectinload
# 2 queries: one for authors, one IN query for books
stmt = select(Author).options(selectinload(Author.books))
authors = session.scalars(stmt).all()
The most efficient strategy for most cases. Uses SELECT ... WHERE author_id IN (1, 2, 3, ...) to batch-fetch related objects. Works well up to thousands of parent IDs.
Nested eager loading
# Load authors → books → reviews in 3 queries
stmt = select(Author).options(
selectinload(Author.books).selectinload(Book.reviews)
)
Cascade configuration
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
books: Mapped[list["Book"]] = relationship(
back_populates="author",
cascade="all, delete-orphan", # delete books when author is deleted
passive_deletes=True, # let the database handle cascades
)
Cascade options:
save-update(default): Adding a parent also adds its children to the sessiondelete: Deleting a parent deletes children via ORMdelete-orphan: Removing a child from the collection deletes itmerge: Merging a parent also merges childrenall: Shorthand for save-update, merge, delete, refresh-expire, expunge
passive_deletes=True paired with ON DELETE CASCADE in the database lets the database handle deletions efficiently, especially for bulk operations.
Performance patterns
Write-through relationship modification
# Adding to a relationship
book = Book(title="New Book")
author.books.append(book) # triggers SQL INSERT on flush
# Bulk association — use direct INSERT for large batches
from sqlalchemy import insert
session.execute(
insert(student_course),
[
{"student_id": 1, "course_id": c_id}
for c_id in range(1, 1001)
]
)
Avoiding lazy loads in serialization
When converting ORM objects to JSON (for APIs), lazy loading can trigger dozens of queries:
# Dangerous: each .to_dict() may trigger lazy loads
return [author.to_dict() for author in authors]
# Safe: eagerly load everything needed for serialization
stmt = select(Author).options(
selectinload(Author.books).selectinload(Book.publisher)
)
authors = session.scalars(stmt).all()
return [author.to_dict() for author in authors]
Configure lazy="raise" on relationships to make accidental lazy loading throw an error instead of silently executing queries:
books: Mapped[list["Book"]] = relationship(
back_populates="author",
lazy="raise", # forces explicit eager loading
)
One thing to remember: Every relationship access is potentially a database query. Choose your loading strategy deliberately — use selectinload as a default, joinedload for single objects with small related sets, and lazy="raise" to catch accidental N+1 queries during development.
See Also
- Python Aioredis Understand Aioredis through a practical analogy so your Python decisions become faster and clearer.
- Python Alembic Understand Alembic through a practical analogy so your Python decisions become faster and clearer.
- Python Asyncpg Database asyncpg is the fastest way for Python to talk to PostgreSQL without making your program sit around waiting.
- Python Asyncpg Understand Asyncpg through a practical analogy so your Python decisions become faster and clearer.
- Python Cassandra Python Understand Cassandra Python through a practical analogy so your Python decisions become faster and clearer.