В базе связь это внешний ключ: колонка customer_id в таблице заказов. В объектах связь это атрибут: order.customer и customer.orders. SQLAlchemy соединяет одно с другим через relationship, и большая часть удобства ORM живёт здесь же, как и большая часть сюрпризов: удаление, которое удалило лишнее, и список, который загрузился на миллион строк.
Один-ко-многим
Внешний ключ объявляют на стороне «многих», а relationship с обеих сторон, связав их через back_populates:
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Customer(Base):
__tablename__ = "customer"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
orders: Mapped[list["Order"]] = relationship(back_populates="customer")
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"))
customer: Mapped["Customer"] = relationship(back_populates="orders")
Mapped[list["Order"]] говорит SQLAlchemy, что это коллекция, Mapped["Customer"] что одиночная ссылка; направление и колонку соединения ORM выводит из ForeignKey. back_populates держит обе стороны согласованными в памяти: после customer.orders.append(order) атрибут order.customer уже указывает на клиента без обращения к базе, и наоборот.
В старом коде встречается backref, который объявляет обе стороны из одного места. Он работает, но вторая сторона появляется неявно, и анализатор типов её не видит; новый код пишут через back_populates с обеих сторон.
Многие-ко-многим
Товар входит в несколько категорий, у категории много товаров. В базе это таблица связи из двух внешних ключей, в SQLAlchemy параметр secondary:
from sqlalchemy import Column, Table
product_category = Table(
"product_category",
Base.metadata,
Column("product_id", ForeignKey("product.id", ondelete="CASCADE"), primary_key=True),
Column("category_id", ForeignKey("category.id", ondelete="CASCADE"), primary_key=True),
)
class Product(Base):
__tablename__ = "product"
id: Mapped[int] = mapped_column(primary_key=True)
categories: Mapped[list["Category"]] = relationship(secondary=product_category, back_populates="products")
class Category(Base):
__tablename__ = "category"
id: Mapped[int] = mapped_column(primary_key=True)
products: Mapped[list["Product"]] = relationship(secondary=product_category, back_populates="categories")
Таблицу связи здесь описывают как Table, а не как класс: у неё нет собственной жизни. Как только у связи появляются свои данные (дата добавления, кто добавил, порядок), она становится сущностью: класс ProductCategory с двумя внешними ключами и полями, и две обычные связи один-ко-многим к нему. Это называют объектом связи, и переход на него лучше делать сразу, как только появилось первое поле: переделывать secondary в класс на живых данных неприятно.
Каскады: что происходит с детьми
Удалили клиента. Что станет с его заказами? У SQLAlchemy на этот вопрос отвечает параметр cascade у relationship, и значения стоит знать точно.
По умолчанию каскад save-update, merge: добавили заказ в customer.orders, и session.add(customer) подхватит и заказ. Удаление детей по умолчанию не каскадируется: при session.delete(customer) SQLAlchemy попробует обнулить customer_id у заказов, и если колонка NOT NULL, упадёт на IntegrityError.
cascade="all, delete-orphan" на стороне родителя означает две вещи: удаление родителя удаляет детей, и ребёнок, вынутый из коллекции (customer.orders.remove(order)), удаляется тоже, потому что без родителя он сирота. Это правильный выбор для частей агрегата: позиции заказа без заказа не существуют.
class Order(Base):
items: Mapped[list["OrderItem"]] = relationship(
back_populates="order", cascade="all, delete-orphan", passive_deletes=True
)
passive_deletes=True в паре с ondelete="CASCADE" на внешнем ключе отдаёт удаление детей базе: SQLAlchemy не грузит коллекцию, чтобы удалить каждого ребёнка отдельным DELETE, а выполняет один DELETE родителя и верит, что база сделает остальное. На заказе с тысячей позиций разница между одним запросом и тысячей.
Чего каскад не делает: не защищает от удаления того, на что ссылаются другие агрегаты. Клиент с заказами не удаляется, и это должно быть ошибкой домена («у клиента есть заказы»), а не IntegrityError из глубины репозитория.
Порядок, фильтр и ссылка на себя
Коллекции по умолчанию без порядка; relationship(order_by="OrderItem.position") даёт стабильный порядок при каждой загрузке. Фильтр в связи (primaryjoin с условием «только активные») возможен, но превращает атрибут в ловушку: customer.orders показывает не все заказы, и об этом никто не помнит; фильтры лучше держать в запросах.
Ссылка на себя (дерево категорий) объявляется с remote_side:
class Category(Base):
parent_id: Mapped[int | None] = mapped_column(ForeignKey("category.id"))
parent: Mapped["Category | None"] = relationship(back_populates="children", remote_side="Category.id")
children: Mapped[list["Category"]] = relationship(back_populates="parent")
Загрузка дерева целиком через children это рекурсия запросов; для глубоких деревьев берут рекурсивный CTE через Core, о чём статья про запросы.
Связь и агрегат
С точки зрения DDD не каждая связь в базе должна становиться relationship. Внутри агрегата (заказ и его позиции) связь нужна: позиции загружаются и сохраняются вместе с заказом. Между агрегатами (заказ и клиент) достаточно колонки customer_id без relationship: заказ хранит идентификатор, а клиента при необходимости загружает свой репозиторий. Так агрегат не тянет за собой пол-базы при загрузке и не меняет чужое состояние через order.customer.name = .... Подробнее о границах в статье про тактические паттерны.
Глубже: взаимная ссылка в типах и ленивые аннотациирасширенное
Классы ссылаются друг на друга, и Mapped["Order"] в кавычках нужен, когда Order объявлен ниже по файлу или в другом модуле. SQLAlchemy разрешает строку по имени класса в реестре Base, поэтому имена классов в одном Base должны быть уникальными на весь сервис, а не на модуль: два класса Item в разных пакетах с общей Base дадут ошибку разрешения связи.
Когда модели разложены по модулям, импортируют их так, чтобы к моменту первого запроса все классы были зарегистрированы: обычно пакет models/__init__.py импортирует все модули, а приложение импортирует пакет. Иначе relationship на класс, который ещё не импортирован, падает при первом обращении с сообщением о неразрешённом имени.
Циклический импорт между модулями моделей решают теми же строковыми аннотациями и TYPE_CHECKING: для анализатора типов класс импортируют под if TYPE_CHECKING:, а SQLAlchemy хватает строки. С Python 3.14 аннотации вычисляются лениво, и кавычки для анализатора больше не нужны, но SQLAlchemy читает их своим способом, так что привычка писать имя строкой пока ничего не портит.
Коротко
- Внешний ключ на стороне «многих»,
relationshipс обеих сторон черезback_populates;backrefэто старый неявный вариант. - Многие-ко-многим через
Tableиsecondary; как только у связи появились свои поля, она становится классом с двумя связями один-ко-многим. - Каскад по умолчанию не удаляет детей;
cascade="all, delete-orphan"для частей агрегата,passive_deletes=Trueсondelete="CASCADE"отдаёт удаление базе одним запросом. - Удаление того, на что ссылаются чужие агрегаты, это ошибка домена, а не каскад.
order_byв связи даёт стабильный порядок коллекции, фильтры в связи делают атрибут обманчивым; ссылка на себя черезremote_side.- Между агрегатами достаточно колонки с идентификатором без
relationship; связи нужны внутри агрегата.
Что почитать дальше
- Ленивая загрузка и N+1 — что происходит при обращении к
order.itemsи как не сделать тысячу запросов. - Сессия и unit of work — как изменения в коллекциях превращаются в INSERT и DELETE.
- Тактические паттерны DDD на Python — где граница агрегата и почему не каждая связь нужна в коде.