SQLAlchemy / Chapter 1 of 4

Models, sessions, and queries

Define SQLAlchemy models and manage sessions, CRUD, and SELECT queries. Compare Python with SQL and load relationships without N+1 queries.

9 topicsUpdated
01 / 27

Mental model, installation, and the 2.x way#

A table holds rows. A row is one record. A column is one value on that record. A primary key uniquely identifies a row. A foreign key points to a row in another table. SQLAlchemy either maps rows to objects (ORM) or lets you work directly with tables and SQL (Core).

EngineLong-lived factory for database connections.
SessionShort-lived ORM unit of work and transaction.
StatementA Python object for SELECT, INSERT, UPDATE, or DELETE.
ShellInstall
# PostgreSQL, synchronous
pip install sqlalchemy "psycopg[binary]"

# PostgreSQL, asynchronous
pip install sqlalchemy asyncpg

# SQLite is included with Python
pip install sqlalchemy
SQLMinimal SQL table
CREATE TABLE user_account (
  id INTEGER PRIMARY KEY,
  name VARCHAR(50) NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL
);
Vocabulary.
A driver speaks a database protocol (such as psycopg). A dialect translates to a database’s SQL. An ORM maps a Python class such as User to a table such as user_account.
02 / 27

Engine, database URLs, drivers, and connection pools#

Create an Engine once at startup. It owns the dialect, DBAPI driver configuration, and a pool of reusable database connections. It does not run SQL until you open a Session or Connection.

PythonPython
from sqlalchemy import URL, create_engine

url = URL.create(
    "postgresql+psycopg",
    username="app", password="p@ss/word",
    host="db.internal", port=5432, database="shop",
)
engine = create_engine(
    url,
    echo=False,           # SQL logging while learning
    pool_size=10, max_overflow=20,
    pool_pre_ping=True,   # test stale connection on checkout
    pool_recycle=1800,
)

sqlite = create_engine("sqlite:///app.db")
SQLWhat the connection executes later
-- Engine opens/reuses a connection. It does not query by itself.
BEGIN;
SELECT 1;
COMMIT;
DatabaseSync URLAsync URL
PostgreSQLpostgresql+psycopg://…postgresql+asyncpg://…
MySQL/MariaDBmysql+pymysql://…mysql+asyncmy://…
SQLitesqlite:///app.dbsqlite+aiosqlite:///app.db
SQL Servermssql+pyodbc://…mssql+aioodbc://…
Oracleoracle+oracledb://…oracle+oracledb://…
Pool sizing is database capacity planning.
Across every web worker, do not configure more checked-out connections than the database or PgBouncer can realistically serve. An engine per request causes connection storms.
03 / 27

Declarative models, mapped columns, defaults, and metadata#

A typed declarative model is a normal class that describes a table. Mapped[T] says what Python value an attribute holds. mapped_column() adds database details such as type, key, index, default, or constraint.

PythonPython model
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50), index=True)
    email: Mapped[str] = mapped_column(String(255), unique=True)
    is_active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    nickname: Mapped[str | None] = mapped_column(nullable=True)

Base.metadata.create_all(engine)  # development/tests only
SQLRepresentative PostgreSQL SQL
CREATE TABLE user_account (
  id SERIAL PRIMARY KEY,
  name VARCHAR(50) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  is_active BOOLEAN NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
  nickname VARCHAR
);
CREATE INDEX ix_user_account_name ON user_account (name);
ItemMeaningUse
primary_key=TrueUnique, non-null row identity.Integer, UUID, or composite key.
default=Python supplies value on INSERT.default=uuid4.
server_default=Database supplies value on INSERT.func.now(); works for non-Python writers.
nullable=FalseDatabase rejects missing value.Inferred from a non-optional type.
index=TrueAdds an index.Use explicit Index for multi-column or vendor indexes.
unique=TruePrevents duplicate values.For multi-column use UniqueConstraint.
04 / 27

Session, object states, flush, commit, and rollback#

A Session tracks objects and controls a transaction. It is mutable and stateful: one Session per request, job, or concurrent task. It is never a global singleton and never shared across threads or async tasks.

PythonSession factory and safe transaction
from sqlalchemy.orm import sessionmaker

SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)

# Commits on normal exit; rolls back if the block raises.
with SessionLocal.begin() as session:
    user = User(name="Ada", email="[email protected]")
    session.add(user)
    # flush happens before commit; user.id is available after flush

# Explicit alternative
with SessionLocal() as session:
    try:
        session.add(User(name="Lin", email="[email protected]"))
        session.commit()
    except:
        session.rollback()
        raise
SQLTransaction SQL
BEGIN;
INSERT INTO user_account (name, email, is_active)
VALUES ('Ada', '[email protected]', true);
COMMIT;

-- Any exception before COMMIT:
ROLLBACK;
TransientUser(): only Python; no Session or database row.
PendingAfter session.add(): queued for INSERT.
PersistentLoaded/flushed object attached to its Session.
DetachedSession closed or expunge(); lazy loading cannot work.
DeletedAfter session.delete(): DELETE queued until flush.
Flush ≠ commitFlush sends SQL; commit makes the transaction permanent.
PythonRefresh, expire, merge, nested transaction
session.flush()               # sends pending INSERT/UPDATE/DELETE
session.refresh(user)         # SELECT current row
session.expire(user)          # reload attributes on next access
attached = session.merge(detached_user)  # copy state into this Session

with session.begin_nested():  # SAVEPOINT
    session.add(optional_audit_record())
SQLSQL concept
SELECT * FROM user_account WHERE id = :id;
SAVEPOINT sa_savepoint_1;
INSERT INTO audit_log (message) VALUES (:message);
RELEASE SAVEPOINT sa_savepoint_1;
Autoflush.
Before a query, Session normally flushes changes so queries see your own writes. Use with session.no_autoflush: only for a narrow construction problem; it does not commit anything.
05 / 27

CRUD with normal ORM objects#

For individual domain objects, load an object, change ordinary Python attributes, then commit. SQLAlchemy’s unit of work compares state and emits the needed SQL.

PythonPython CRUD
from sqlalchemy import select

with SessionLocal.begin() as session:
    # CREATE
    user = User(name="Ada", email="[email protected]")
    session.add(user)

    # READ by primary key (identity-map aware)
    same_user = session.get(User, user.id)

    # UPDATE
    same_user.name = "Ada Lovelace"

    # DELETE
    session.delete(same_user)
SQLEquivalent SQL
INSERT INTO user_account (name, email, is_active)
VALUES ('Ada', '[email protected]', true);
SELECT * FROM user_account WHERE id = 1;
UPDATE user_account SET name = 'Ada Lovelace' WHERE id = 1;
DELETE FROM user_account WHERE id = 1;
PythonExactly one / optional / all
stmt = select(User).where(User.email == "[email protected]")

first_or_none = session.scalar(stmt)             # first scalar or None
exactly_one = session.scalars(stmt).one()        # raises for 0 or 2+
zero_or_one = session.scalars(stmt).one_or_none() # raises for 2+
all_users = session.scalars(select(User)).all()  # list[User]
SQLSQL
SELECT id, name, email, is_active, created_at, nickname
FROM user_account WHERE email = '[email protected]';
After a failed flush or commit, roll back.
A failed database transaction cannot continue until rolled back. The Session.begin() context manager does it automatically.
06 / 27

SELECT statements and result shapes#

Build select(), then execute it. The selected items decide the result: ORM objects, scalar values, Row tuples, or mapping/dict-like values.

PythonObjects, Rows, mappings
from sqlalchemy import select

users = session.scalars(
    select(User).where(User.is_active)
).all()

stmt = select(User.id, User.name).order_by(User.name)
for row in session.execute(stmt):
    print(row.id, row.name)        # attribute
    print(row[0], row[1])          # tuple
    print(row._mapping["name"])    # mapping

rows = session.execute(stmt).mappings().all()
# [{"id": 1, "name": "Ada"}, ...]
SQLEquivalent SQL
SELECT id, name, email, is_active, created_at, nickname
FROM user_account WHERE is_active = true;

SELECT id, name FROM user_account ORDER BY name ASC;

Result methods, row counts, and identity

select(User) asks for whole mapped objects; select(User.id, User.name) asks for columns. scalars() keeps only the first selected item in each row. scalar_one() requires exactly one row and returns its first item; scalar_one_or_none() allows zero or one. first() does not add a SQL LIMIT. Session.get() finds an object by primary key and can use the session’s identity map without querying again. Use count() for a count; len(scalars(...).all()) transfers all rows to Python.

PythonSQLAlchemy 2.x
from sqlalchemy import func, select

user = session.get(User, 42)  # primary key lookup; may use identity map
user = session.execute(
    select(User).where(User.email == "[email protected]")
).scalar_one_or_none()

rows = session.execute(
    select(User.id, User.name).order_by(User.id).limit(10)
).mappings().all()  # keys: "id", "name"; values are not User objects

total = session.scalar(select(func.count()).select_from(User))
assert total is not None
SQLEquivalent SQL
SELECT * FROM user_account WHERE id = 42;
SELECT * FROM user_account WHERE email = :email;
SELECT id, name FROM user_account ORDER BY id LIMIT 10;
SELECT count(*) FROM user_account;

Joins, outer joins, and duplicate parents

A JOIN keeps parents with matching children; LEFT OUTER JOIN also keeps parents with none. Joining a one-to-many collection produces one SQL row per matching child, so a parent can appear multiple times. Use .unique() when you want unique ORM parent objects. An ordinary join filters/returns rows; it does not fill user.addresses (see the loading section).

PythonSQLAlchemy 2.x
from sqlalchemy import func, select

# Users who have an address, ordered by that address.
stmt = (select(User).join(User.addresses)
        .where(Address.email.like("%@example.com"))
        .order_by(Address.email))
users = session.scalars(stmt).unique().all()

# Keep users without addresses too; select both objects.
stmt = select(User, Address).outerjoin(User.addresses)
for user, address in session.execute(stmt):
    print(user.name, address.email if address else None)

# Count distinct parents after joining a collection.
count = session.scalar(select(func.count(func.distinct(User.id)))
                       .join(User.addresses))
SQLEquivalent SQL
SELECT u.* FROM user_account AS u
JOIN address AS a ON a.user_id = u.id
WHERE a.email LIKE '%@example.com' ORDER BY a.email;

SELECT u.*, a.* FROM user_account AS u
LEFT OUTER JOIN address AS a ON a.user_id = u.id;

SELECT count(DISTINCT u.id) FROM user_account AS u
JOIN address AS a ON a.user_id = u.id;

Aliases and explicit join paths

An alias gives a table another name so you can refer to it twice. Use aliased() for self joins or multiple instances of one mapped class. of_type() tells a relationship join to use that alias. join_from() fixes the left side when several tables are already present; specify an ON condition when the desired join is not unambiguous.

PythonSQLAlchemy 2.x
from sqlalchemy.orm import aliased

manager = aliased(Employee, name="manager")
stmt = (select(Employee.name, manager.name.label("manager_name"))
        .join(manager, Employee.manager_id == manager.id))

home = aliased(Address, name="home")
stmt = (select(User, home)
        .join(User.addresses.of_type(home))
        .where(home.email.like("%@home.test")))

# With multiple FROMs, make the join's starting point explicit.
stmt = select(User.name, Address.email).join_from(User, Address)
SQLEquivalent SQL
SELECT employee.name, manager.name AS manager_name
FROM employee JOIN employee AS manager
ON employee.manager_id = manager.id;

SELECT u.*, home.* FROM user_account AS u
JOIN address AS home ON u.id = home.user_id
WHERE home.email LIKE '%@home.test';

SELECT u.name, a.email FROM user_account AS u
JOIN address AS a ON u.id = a.user_id;
CallOutputUse when
session.execute(stmt)Result[Row]Several columns or low-level result behavior.
session.scalars(stmt)ScalarResult[T]One entity / scalar selected per row.
.all()ListResult is bounded.
.first()First / NoneAdd limit(1) when cost matters.
.one()Exactly oneFewer/more is a logic or data error.
.unique()Deduplicated entitiesRequired for collection joinedload() results.
.partitions(500)BatchesIncremental processing.
Safe parameters.
select(User).where(User.email == user_input) binds values safely. For exact SQL, use text("… WHERE email = :email") plus a parameters dictionary—never string-concatenate user input into SQL.
07 / 27

WHERE, boolean logic, ordering, offsets, and keyset pagination#

Python comparisons create SQL expressions. Parenthesize &, |, and ~, or use and_(), or_(), and not_().

PythonFiltering and pagination
stmt = (
    select(User)
    .where(
        User.is_active,
        User.name.ilike("a%"),
        User.id.in_([1, 2, 3]),
        User.nickname.is_not(None),
        (User.name == "Ada") | (User.name == "Grace"),
    )
    .order_by(User.created_at.desc(), User.id.asc())
    .limit(20).offset(40)
)
SQLEquivalent SQL
SELECT * FROM user_account
WHERE is_active = true
  AND name ILIKE 'a%'
  AND id IN (1, 2, 3)
  AND nickname IS NOT NULL
  AND (name = 'Ada' OR name = 'Grace')
ORDER BY created_at DESC, id ASC
LIMIT 20 OFFSET 40;
==, !=Equality / inequality. Use is_(None) for NULL.
in_(), not_in()SQL IN; can take a list or select.
like(), ilike()Pattern match; also contains, starts/ends with.
between()Inclusive range.
asc(), desc()Ordering; nulls_last()/nulls_first() where supported.
distinct()Eliminate duplicate selected row values.
PythonKeyset pagination for large changing lists
from sqlalchemy import and_, or_

stmt = select(User).order_by(User.created_at.desc(), User.id.desc()).limit(50)
if cursor:
    stmt = stmt.where(or_(
        User.created_at < cursor.created_at,
        and_(User.created_at == cursor.created_at, User.id < cursor.id),
    ))
SQLEquivalent SQL
SELECT * FROM user_account
WHERE created_at < :last_created_at
   OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Reusable conditions, NULL, IN, and string matching

Each where() call adds another AND condition. Python None becomes SQL IS NULL; use .is_(None) explicitly for clarity. in_() produces an IN condition and handles an empty sequence safely. like() uses SQL wildcards % (many characters) and _ (one); ilike() is case insensitive where supported or emulated.

PythonSQLAlchemy 2.x
from sqlalchemy import or_, select

stmt = select(User)
if term:
    stmt = stmt.where(or_(User.name.ilike(f"%{term}%"),
                           User.email.ilike(f"%{term}%")))
if ids is not None:
    stmt = stmt.where(User.id.in_(ids))  # [] yields no matches
stmt = stmt.where(User.nickname.is_(None), User.is_active.is_(True))

# Values are bound as parameters; escape %, _ yourself if term is literal.
users = session.scalars(stmt.order_by(User.id)).all()
SQLEquivalent SQL
SELECT * FROM user_account
WHERE (name ILIKE :pattern OR email ILIKE :pattern)
  AND id IN (:id_1, :id_2)  -- illustrative nonempty list
  AND nickname IS NULL AND is_active IS TRUE
ORDER BY id;
-- An empty IN list is compiled as an always-false condition.

Group, aggregate, filter groups, and page safely

GROUP BY combines rows sharing a key; count() counts each group. HAVING filters groups after counting, while WHERE filters source rows first. An explicit stable ORDER BY is essential for paging. Offset pagination can shift while rows are inserted; the keyset example above uses the last seen timestamp plus a unique ID for a stable cursor.

PythonSQLAlchemy 2.x
from sqlalchemy import func, select

stmt = (select(User.id, User.name,
               func.count(Address.id).label("address_count"))
        .outerjoin(User.addresses)
        .group_by(User.id, User.name)
        .having(func.count(Address.id) >= 2)
        .order_by(func.count(Address.id).desc(), User.id)
        .limit(20))
for user_id, name, n in session.execute(stmt):
    print(user_id, name, n)
SQLEquivalent SQL
SELECT u.id, u.name, count(a.id) AS address_count
FROM user_account AS u
LEFT JOIN address AS a ON a.user_id = u.id
GROUP BY u.id, u.name
HAVING count(a.id) >= 2
ORDER BY count(a.id) DESC, u.id LIMIT 20;
08 / 27

Foreign keys, relationships, joins, and association tables#

A foreign key enforces a database reference. A relationship gives that reference a Python attribute and lets SQLAlchemy understand joins. A relationship does not replace the foreign key.

Independent mapping examples

The mapping recipes use separate schemas. Repeated class names such as User and Address show alternative configurations; combine only the fields and relationships your schema needs.

PythonOne-to-many / many-to-one
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import relationship

class Address(Base):
    __tablename__ = "address"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255))
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    user: Mapped["User"] = relationship(back_populates="addresses")

class User(Base):
    __tablename__ = "user_account"
    # mapped columns omitted
    addresses: Mapped[list[Address]] = relationship(
        back_populates="user", cascade="all, delete-orphan"
    )

user.addresses.append(Address(email="[email protected]"))
SQLEquivalent SQL
CREATE TABLE address (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  user_id INTEGER NOT NULL REFERENCES user_account(id)
);
INSERT INTO address (email, user_id)
VALUES ('[email protected]', :user_id);

Find parents with, without, or matching children

any() builds an EXISTS test for a collection; has() does the same for a scalar many-to-one relationship. Prefix either expression with ~ for NOT EXISTS. These tests keep one result row per parent without joining and multiplying it. For one related row already in Python, a relationship comparison such as Address.user == user compares its foreign key with that user’s key.

PythonJoins and relationship operators
# JOIN via a declared relationship
stmt = select(User).join(User.addresses).where(
    Address.email.endswith("@example.com")
)
users = session.scalars(stmt).unique().all()

# EXISTS expressions without spelling the join
work_users = select(User).where(
    User.addresses.any(Address.email.like("%@work.com"))
)
adas_addresses = select(Address).where(
    Address.user.has(User.name == "Ada")
)
# Users with no addresses; keep one row per User.
no_addresses = session.scalars(
    select(User).where(~User.addresses.any())
).all()
# A known parent instance is compared using its primary key.
owned = select(Address).where(Address.user == user)
SQLEquivalent SQL
SELECT u.* FROM user_account AS u
JOIN address AS a ON u.id = a.user_id
WHERE a.email LIKE '%@example.com';

SELECT u.* FROM user_account AS u WHERE EXISTS (
  SELECT 1 FROM address a
  WHERE a.user_id = u.id AND a.email LIKE '%@work.com'
);

SELECT a.* FROM address AS a WHERE EXISTS (
  SELECT 1 FROM user_account AS u
  WHERE u.id = a.user_id AND u.name = 'Ada'
);

SELECT u.* FROM user_account AS u WHERE NOT EXISTS (
  SELECT 1 FROM address AS a WHERE a.user_id = u.id
);
SELECT a.* FROM address AS a WHERE a.user_id = :user_id;
PythonMany-to-many pure link table
from sqlalchemy import Column, Table

user_role = Table(
    "user_role", Base.metadata,
    Column("user_id", ForeignKey("user_account.id"), primary_key=True),
    Column("role_id", ForeignKey("role.id"), primary_key=True),
)
class Role(Base):
    __tablename__ = "role"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(40), unique=True)
    users: Mapped[list["User"]] = relationship(
        secondary=user_role, back_populates="roles"
    )
User.roles = relationship(Role, secondary=user_role, back_populates="users")
SQLEquivalent SQL
CREATE TABLE user_role (
  user_id INTEGER REFERENCES user_account(id),
  role_id INTEGER REFERENCES role(id),
  PRIMARY KEY (user_id, role_id)
);

One-to-one: a unique foreign key

A scalar Mapped[Profile] attribute expresses one related object in Python. The database’s UNIQUE constraint on user_id actually prevents two profiles for one user. The foreign key lives in the child table; back_populates links both Python attributes. Keep Profile non-nullable only if every stored profile needs a user; a user can still have no profile.

PythonSQLAlchemy 2.x
class User(Base):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    profile: Mapped["Profile | None"] = relationship(back_populates="user")

class Profile(Base):
    __tablename__ = "profile"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(
        ForeignKey("user_account.id"), unique=True
    )
    bio: Mapped[str] = mapped_column(String(200))
    user: Mapped[User] = relationship(back_populates="profile")

user.profile = Profile(bio="Database learner")
SQLEquivalent SQL
CREATE TABLE profile (
  id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  user_id INTEGER NOT NULL UNIQUE REFERENCES user_account(id),
  bio VARCHAR(200) NOT NULL
);
INSERT INTO profile (user_id, bio) VALUES (:user_id, 'Database learner');

Association objects: data on a link

A plain many-to-many link table works when the link stores only two foreign keys. When a link has its own data, such as a membership role or join date, map the link as a class. Mutate Membership objects; use association_proxy to expose convenient group.users style access when useful. Avoid mapping the same link both as a writable secondary many-to-many and as an association object without carefully coordinating writes.

PythonSQLAlchemy 2.x
from sqlalchemy.ext.associationproxy import association_proxy

class Membership(Base):
    __tablename__ = "membership"
    user_id: Mapped[int] = mapped_column(
        ForeignKey("user_account.id"), primary_key=True)
    group_id: Mapped[int] = mapped_column(
        ForeignKey("group_account.id"), primary_key=True)
    role: Mapped[str] = mapped_column(String(30))
    user: Mapped["User"] = relationship(back_populates="memberships")
    group: Mapped["Group"] = relationship(back_populates="memberships")

class User(Base):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    memberships: Mapped[list[Membership]] = relationship(back_populates="user")
    groups = association_proxy("memberships", "group",
                               creator=lambda g: Membership(group=g, role="member"))

class Group(Base):
    __tablename__ = "group_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    memberships: Mapped[list[Membership]] = relationship(back_populates="group")

user.memberships.append(Membership(group=group, role="admin"))
SQLEquivalent SQL
CREATE TABLE membership (
  user_id INTEGER NOT NULL REFERENCES user_account(id),
  group_id INTEGER NOT NULL REFERENCES group_account(id),
  role VARCHAR(30) NOT NULL,
  PRIMARY KEY (user_id, group_id)
);
INSERT INTO membership (user_id, group_id, role)
VALUES (:user_id, :group_id, 'admin');

Trees and multiple foreign key paths

A self-referential table can model a tree: each node’s parent_id points to another row in the same table. remote_side identifies the parent side of that relationship. If two foreign keys point to the same table, SQLAlchemy cannot guess which one a relationship means; set foreign_keys separately. The database must still enforce those foreign keys.

PythonSQLAlchemy 2.x
class Node(Base):
    __tablename__ = "node"
    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int | None] = mapped_column(ForeignKey("node.id"))
    parent: Mapped["Node | None"] = relationship(
        back_populates="children", remote_side=[id]
    )
    children: Mapped[list["Node"]] = relationship(back_populates="parent")

class Shipment(Base):
    __tablename__ = "shipment"
    id: Mapped[int] = mapped_column(primary_key=True)
    sender_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    recipient_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    sender: Mapped["User"] = relationship(foreign_keys=[sender_id])
    recipient: Mapped["User"] = relationship(foreign_keys=[recipient_id])
SQLEquivalent SQL
CREATE TABLE node (
  id INTEGER PRIMARY KEY,
  parent_id INTEGER NULL REFERENCES node(id)
);
SELECT child.* FROM node AS child WHERE child.parent_id = :parent_id;

CREATE TABLE shipment (
  id INTEGER PRIMARY KEY,
  sender_id INTEGER NOT NULL REFERENCES user_account(id),
  recipient_id INTEGER NOT NULL REFERENCES user_account(id)
);
SELECT s.*, sender.*, recipient.* FROM shipment AS s
JOIN user_account AS sender ON sender.id = s.sender_id
JOIN user_account AS recipient ON recipient.id = s.recipient_id;

Delete cascade: ORM versus database

cascade='all, delete-orphan' asks the ORM to delete a child removed from its owner’s collection; this fits truly owned children. ondelete='CASCADE' is a database foreign key rule that also works for deletes outside the ORM. passive_deletes=True tells the ORM to rely on that database rule for unloaded children while it still handles loaded children consistently. Enable foreign key enforcement in SQLite; otherwise its ON DELETE rule may have no effect.

PythonSQLAlchemy 2.x
class Project(Base):
    __tablename__ = "project"
    id: Mapped[int] = mapped_column(primary_key=True)
    tasks: Mapped[list["Task"]] = relationship(
        back_populates="project", cascade="all, delete-orphan",
        passive_deletes=True,
    )

class Task(Base):
    __tablename__ = "task"
    id: Mapped[int] = mapped_column(primary_key=True)
    project_id: Mapped[int] = mapped_column(
        ForeignKey("project.id", ondelete="CASCADE")
    )
    project: Mapped[Project] = relationship(back_populates="tasks")

project.tasks.remove(task)  # ORM emits DELETE for this orphan on flush
session.delete(project)     # database deletes unloaded tasks on DELETE
SQLEquivalent SQL
CREATE TABLE project (id INTEGER PRIMARY KEY);
CREATE TABLE task (
  id INTEGER PRIMARY KEY,
  project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE
);
DELETE FROM task WHERE id = :removed_task_id; -- orphan removal
DELETE FROM project WHERE id = :project_id;   -- DB cascades tasks

Custom joins, read-only views, and composite keys

primaryjoin can restrict which child rows a relationship loads. A filtered relationship is not a database constraint: the filter does not validate Python assignments or automatically rewrite the child’s value. Use viewonly=True for a convenient read-only view, and modify the ordinary relationship to persist changes. For multi-column keys, use ForeignKeyConstraint to describe the pair of columns together.

PythonSQLAlchemy 2.x
from sqlalchemy import ForeignKeyConstraint, and_

class User(Base):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    addresses: Mapped[list["Address"]] = relationship(back_populates="user")
    work_addresses: Mapped[list["Address"]] = relationship(
        primaryjoin=lambda: and_(User.id == Address.user_id,
                                 Address.kind == "work"),
        viewonly=True,
    )

class OrderLine(Base):
    __tablename__ = "order_line"
    order_id: Mapped[int] = mapped_column(primary_key=True)
    line_no: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[int] = mapped_column()
    product_id: Mapped[int] = mapped_column()
    __table_args__ = (ForeignKeyConstraint(
        ["tenant_id", "product_id"], ["product.tenant_id", "product.id"]
    ),)
SQLEquivalent SQL
SELECT a.* FROM address AS a
WHERE a.user_id = :user_id AND a.kind = 'work';

CREATE TABLE order_line (
  order_id INTEGER NOT NULL, line_no INTEGER NOT NULL,
  tenant_id INTEGER NOT NULL, product_id INTEGER NOT NULL,
  PRIMARY KEY (order_id, line_no),
  FOREIGN KEY (tenant_id, product_id)
    REFERENCES product(tenant_id, id)
);
PatternTypeKey configuration
Many-to-oneMapped[User]Foreign key on this table; scalar relationship.
One-to-manyMapped[list[Address]]Foreign key on other table; back_populates.
One-to-oneMapped[Profile | None]Scalar relationship plus unique foreign key.
Many-to-manyMapped[list[Role]]secondary=link_table for a pure link table.
Association objectMapped[list[Membership]]Map link table as a class if it has role/time/status data.
Self-referentialMapped[Node | None]Use remote_side to identify parent side.
ThingWhere it livesWhat it controls
ForeignKeyDatabase columnWhich referenced rows are valid; nullable decides whether missing parents are allowed.
relationship()Python mappingNavigation and ORM synchronization; no column is created by itself.
back_populatesBoth mapped classesKeeps both Python directions of one association in sync.
secondaryRelationship mappingConnects a plain many-to-many link table.
cascadeORM relationshipHow Session actions propagate to associated objects.
ON DELETEDatabase foreign keyHow the database reacts when a referenced row is deleted.
Delete-orphan cascade

delete-orphan deletes a child removed from its owner’s collection. Use it for children that belong to one parent, and coordinate ORM cascade with database ON DELETE rules.

09 / 27

Lazy loading, eager loading, and preventing N+1 queries#

Accessing a relationship can emit a lazy SELECT. If a loop does it for 50 parents, that is an N+1 query problem. Put the loading decision on the query whose response needs the related data.

PythonSelect-in, joined, and raise loading
from sqlalchemy.orm import joinedload, raiseload, selectinload

# Usually best for a collection: two predictable queries
users = session.scalars(
    select(User).options(selectinload(User.addresses))
).all()

# Good for a scalar many-to-one or one-to-one
addresses = session.scalars(
    select(Address).options(joinedload(Address.user))
).all()

# Development/async guard: unexpected lazy access raises
stmt = select(User).options(raiseload("*"))
SQLRepresentative SQL
-- selectinload(User.addresses)
SELECT u.* FROM user_account u;
SELECT a.* FROM address a WHERE a.user_id IN (1, 2, 3, ...);

-- joinedload(Address.user)
SELECT a.*, u.* FROM address a
LEFT OUTER JOIN user_account u ON u.id = a.user_id;

Choose loaders without changing query results

selectinload() runs a second query using parent IDs and usually suits collections. joinedload() joins related rows for loading without making the relationship available for a regular WHERE or ORDER BY; collection joined loading multiplies SQL rows and therefore requires Result.unique(). Chain options for nested paths. Use an explicit join() if you need to filter/order by a related table.

PythonSQLAlchemy 2.x
from sqlalchemy.orm import joinedload, selectinload

stmt = select(User).options(
    selectinload(User.roles).selectinload(Role.users)
)
users = session.scalars(stmt).all()  # user, then address, then message SQL

# Collection joinedload duplicates SQL rows; unique() collapses ORM parents.
stmt = select(User).options(joinedload(User.addresses))
users = session.scalars(stmt).unique().all()

# Filter on Address separately from the loader choice.
stmt = (select(User).join(User.addresses)
        .where(Address.email.like("%@work.com"))
        .options(selectinload(User.addresses)))
SQLEquivalent SQL
SELECT * FROM user_account;
SELECT r.* FROM role AS r JOIN user_role ur ON ur.role_id = r.id
WHERE ur.user_id IN (:id_1, :id_2, ...);
SELECT u.* FROM user_account AS u JOIN user_role ur ON ur.user_id = u.id
WHERE ur.role_id IN (:role_1, :role_2, ...);

SELECT u.*, a.* FROM user_account AS u
LEFT OUTER JOIN address AS a ON a.user_id = u.id;

SELECT u.* FROM user_account AS u
JOIN address AS a ON a.user_id = u.id WHERE a.email LIKE '%@work.com';
-- selectinload then separately fetches all addresses for matched users.

Filter a loaded collection; route your own join

.and_() filters a loader’s child rows. If that collection was loaded earlier in the same session, populate_existing=True refreshes it with the new condition. contains_eager() routes a join you wrote into the relationship attribute; use it when the same join must filter/order and populate a custom child collection. A filtered collection is a partial view; it is not the complete relationship.

PythonSQLAlchemy 2.x
from sqlalchemy.orm import contains_eager, selectinload

stmt = (select(User).options(
            selectinload(User.addresses.and_(Address.email.like("%@work.com")))
        ).execution_options(populate_existing=True))
users = session.scalars(stmt).all()

stmt = (select(User).join(User.addresses)
        .where(Address.email.like("%@work.com"))
        .options(contains_eager(User.addresses))
        .execution_options(populate_existing=True))
users = session.scalars(stmt).unique().all()
SQLEquivalent SQL
SELECT u.* FROM user_account AS u;
SELECT a.* FROM address AS a
WHERE a.user_id IN (:id_1, :id_2, ...) AND a.email LIKE '%@work.com';

SELECT u.*, a.* FROM user_account AS u
JOIN address AS a ON a.user_id = u.id
WHERE a.email LIKE '%@work.com';

Only needed columns, global filters, and huge collections

load_only() defers other non-key columns; accessing them later may emit SQL. raiseload=True catches accidental column access, and raiseload('*') catches unexpected relationship lazy loads. with_loader_criteria() can apply a condition wherever an entity appears in a query and its loaders (useful for tenant visibility; design authorization separately). For a huge collection, WriteOnlyMapped supports SQL-based selects and mutations without loading the whole collection into Python.

PythonSQLAlchemy 2.x
from sqlalchemy.orm import (WriteOnlyMapped, load_only, raiseload,
                            selectinload, with_loader_criteria)

stmt = select(User).options(
    load_only(User.id, User.name, raiseload=True),
    selectinload(User.addresses),
    with_loader_criteria(Address, Address.email.like("%@example.com")),
)
users = session.scalars(stmt).all()

# Alternative mapping for a very large child collection:
class AuditLog(Base):
    __tablename__ = "audit_log"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))

class User(Base):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    logs: WriteOnlyMapped[AuditLog] = relationship()

logs = session.scalars(user.logs.select().limit(50)).all()
user.logs.add(AuditLog())
SQLEquivalent SQL
SELECT id, name FROM user_account;
SELECT a.* FROM address AS a
WHERE a.user_id IN (:id_1, :id_2, ...) AND a.email LIKE '%@example.com';

SELECT * FROM audit_log WHERE user_id = :user_id LIMIT 50;
INSERT INTO audit_log (user_id) VALUES (:user_id);
OptionBest useBehavior
selectinload()Collections / API responsesSecond SELECT with an IN list; avoids row multiplication.
joinedload()Scalar relationLoads with a JOIN. For collection joinedload, consume with unique().
subqueryload()Special casesUses a subquery; normally prefer select-in.
contains_eager()You made the join yourselfPopulate relationship from explicit join rows.
lazyload()/noload()Override mapping defaultLazy load / do not populate data.
load_only(), defer()Large columnsLoad selected columns only or postpone expensive columns.
with_loader_criteria()Query-wide related filterAdd a condition every time an entity loads in the statement.