Прямой ответ
Три правила покрывают большинство проблем SQLAlchemy. Первое: сессия = единица работы — короткая, на операцию/запрос, с явным коммитом и закрытием (with Session(engine) as s:), а не глобальный объект на всё приложение. Второе: стиль 2.0 — select(Model).where(...) вместо устаревшего session.query(...): он типизируем (дружит с mypy), един для ORM и Core, и это будущее библиотеки. Третье: ленивая загрузка — осознанная — дефолтное lazy='select' удобно в прототипе и убивает производительность в проде запросами N+1; отношения подгружайте явно (joinedload/selectinload) там, где они нужны.
Сессия: единица работы, а не singleton
Сессия держит identity map (объект из БД — один экземпляр в памяти), трекает изменения для flush и управляет транзакцией. Жизненный цикл по запросу: создать → работать → commit/rollback → close; в веб-фреймворках это фикстура/DI на запрос (FastAPI-зависимость с yield). Антипаттерны: глобальная сессия (конфликты потоков, раздутый identity map, «залипшие» состояния); сессия, живущая дольше операции (объекты устаревают, ленивые подгрузки стреляют в неожиданный момент); работа после rollback без явного переоткрытия. Отдельная ловушка — DetachedInstanceError: обращение к атрибуту объекта после закрытия сессии (ленивая подгрузка невозможна) — лечится загрузкой нужного до отцепления (selectinload в запросе или expire_on_commit=False, когда осмысленно).
Запросы в стиле 2.0
Канонический вид: stmt = select(User).where(User.email == email); user = session.execute(stmt).scalar_one_or_none() — и его преимущества ощутимы: полная типизация (автодополнение по полям моделей), единообразие с Core-запросами, явность результата (scalars(), scalar_one(), all() — каждый со своей семантикой «один/много/нет»). Полезные конструкции: join()/outerjoin() явными (лучше читаются, чем магическая ленивая подгрузка), selectinload/joinedload в options() для отношений, func.count() для агрегатов, returning() для атомарного insert/update с получением результата. Миграция со старого session.query механическая (есть режим совместимости), но новый код пишется только в 2.0-стиле.
Ленивая загрузка и N+1: главная ловушка ORM
lazy='select' (дефолт) означает: отношение подгрузится отдельным запросом при первом обращении — в цикле по 100 заказам обращение к order.user даст 101 запрос. Три инструмента решения: selectinload(Order.user) — один дополнительный запрос IN-ом на всю коллекцию (универсальный выбор, работает и для списков); joinedload — JOIN в основном запросе (хорош для one-to-one/many-to-one, осторожен с коллекциями — дубли строк); lazy='raise' на отношении — запрет ленивой подгрузки с явной ошибкой (дисциплина «или подгрузил явно, или не трогай»). Диагностика — echo=True на engine в разработке (виден каждый SQL) или подсчёт запросов в тестах; разбор проблемы в общем виде — в статье про N+1.
Транзакции, flush и коммит: кто что делает
Путаница частая: flush отправляет накопленные изменения в БД в рамках транзакции (автоперед запросами, чтобы они видели ваши правки), commit фиксирует транзакцию. Практические следствия: «изменил объект, но не вижу в БД» — не было коммита (сессия сама не коммитит); session.add() не делает INSERT немедленно — он во flush; атомарность — на уровне транзакции, «частичный коммит» внутри одной сессии — через вложенные (savepoint) или отдельные сессии. Правило чистоты: бизнес-операция — одна транзакция; долгоживущие «транзакции на минуту» с сетевыми вызовами внутри — источник блокировок и таймаутов.
Схема и миграции: модели ≠ таблицы
Модели SQLAlchemy описывают ожидаемую схему; сама схема в БД меняется только миграциями — Alembic (автогенерация диффа моделей, ревизии, апгрейд/даунгрейд) — практический гайд в статье про Alembic. Base.metadata.create_all() годится для тестов и песочниц, но не для прода: не умеет изменения существующих таблиц. Локальная БД для разработки поднимается отдельным стеком — инструкция для PostgreSQL в статье про локальный PostgreSQL; для тестов — Testcontainers, а не продакшн-подключение.
Практический сценарий: веб-запрос от роутера до коммита
Проследим жизненный цикл одного POST /orders в FastAPI-приложении, чтобы роль сессии стала очевидной. Запрос приходит; зависимость get_db создаёт Session(engine) — новую, чистую, только для этого запроса. Бизнес-сервис получает её как параметр: читает пользователя (select(User).where(...) — один запрос), конструирует объект Order и позиции, session.add(order) — объекты пока только в памяти сессии. При первом же следующем запросе (или явно session.flush()) накопленное уходит в БД в рамках транзакции — INSERT'ы выполняются, у заказа появляется id. Сервис делает проверку бизнес-правила, читая только что добавленные позиции — они видны благодаря авто-flush. Всё хорошо — зависимость делает session.commit(); на исключении — rollback в том же finally-блоке, и частичного заказа в БД не существует. После коммита атрибуты объекта по умолчанию «протухают» (expire_on_commit) — если ответу нужны данные заказа, они читаются до коммита или сессия настроена на expire_on_commit=False. Сессия закрывается — объекты отцеплены: любая попытка ленивой подгрузки после этого — DetachedInstanceError, и ответ собирается из уже прочитанного. Один запрос — одна сессия — одна транзакция: вся сложность SQLAlchemy укладывается в эту рамку, а выход за неё (глобальная сессия, ручные коммиты в трёх местах) — источник тех самых «необъяснимых» багов.
FAQ
ORM или Core (чистый SQL)? Не или/или: CRUD и связи — ORM; сложные аналитические запросы, массовые update/delete, специфичные SQL-функции — Core/текстовые запросы в той же сессии. ORM не должен быть клеткой.
Нужен ли async-движок (asyncio)? Только если приложение асинхронное (asyncpg/asyncmy-драйверы, AsyncSession). Для синхронных веб-приложений обычный движок с пулом соединений проще и быстрее; смешивать синхронную сессию с async-кодом нельзя — блокирует loop.
Почему объекты «сбрасываются» после коммита?
По умолчанию expire_on_commit=True: после commit атрибуты помечаются устаревшими и перезагружаются при обращении. Для DTO-паттерна (объект живёт после коммита) ставят expire_on_commit=False или читают данные до коммита.
Как считать записи и пагинировать эффективно?
select(func.count()).select_from(Model).where(...) для точного числа (дорого на больших таблицах — оцените приближение), для пагинации — keyset/seek (where(Model.id > last_id).limit(N)) вместо OFFSET на глубоких страницах.
Источники: документация SQLAlchemy 2.0 (session basics, select guide, loading relationships) — ссылки в шапке.