Session это самая важная и самая непонятная часть ORM. Она не «соединение с базой» и не «транзакция», хотя держит и то и другое. Сессия это рабочая область: она помнит, какие объекты вы загрузили, что в них поменяли, и в нужный момент превращает разницу в SQL. Этот приём называется unit of work, и почти все вопросы вида «почему данные не сохранились» или «почему объект вдруг пошёл в базу» это вопросы о том, в каком состоянии сессия и объект.
Четыре состояния объекта
У каждого объекта модели есть состояние относительно сессии, и его можно спросить:
from sqlalchemy import inspect
customer = Customer(name="Анна")
inspect(customer).transient # True: объект есть, сессия о нём не знает
session.add(customer)
inspect(customer).pending # True: сессия знает, INSERT ещё не выполнен
session.flush()
inspect(customer).persistent # True: строка в базе, у объекта есть первичный ключ
session.close()
inspect(customer).detached # True: объект жив, сессии больше нет
Transient это обычный объект Python. Pending появляется после session.add и живёт до flush. Persistent это объект, у которого есть строка в базе и который сессия отслеживает: любое изменение атрибута она заметит. Detached это объект, переживший свою сессию: его можно читать, пока атрибуты загружены, но обращение к незагруженному или протухшему атрибуту падает с DetachedInstanceError.
Identity map: один объект на одну строку
Внутри одной сессии строка таблицы представлена ровно одним объектом. Два session.get(Order, 1) вернут один и тот же экземпляр, второй вызов даже не пойдёт в базу. Запрос select(Order), который вернул строку с уже загруженным идентификатором, отдаст тот же объект, что лежит в сессии, а не новый.
Следствие полезное: изменения, сделанные через одну ссылку, видны через любую другую в той же сессии. Следствие неожиданное: повторный запрос не обновляет уже загруженный объект свежими данными из базы, пока его не попросить явно (session.refresh(order) или populate_existing у запроса). Если в двух местах кода «один и тот же заказ показывает разное», почти всегда это две разные сессии.
Flush и commit это разные вещи
flush отправляет накопленные изменения в базу как SQL внутри текущей транзакции: INSERT для pending-объектов, UPDATE по изменённым колонкам, DELETE для удалённых. После flush строки видны этой же транзакции, у новых объектов появляются первичные ключи, но для других соединений ничего не произошло.
commit делает flush, затем COMMIT, и изменения становятся видны всем. rollback отменяет и SQL, и изменения в объектах, которые вернутся к последнему зафиксированному состоянию.
Flush вызывают сами редко; обычно его делает сессия, и делает чаще, чем кажется. Autoflush: перед каждым запросом сессия сбрасывает накопленные изменения, чтобы запрос увидел их. Поэтому такая последовательность работает:
session.add(Customer(name="Анна"))
count = session.scalar(select(func.count()).select_from(Customer)) # уже учитывает Анну
Обратная сторона: ошибка IntegrityError может вылететь не на session.add, а на невинном SELECT тремя строками ниже, потому что именно там случился flush. Когда стек ошибки показывает запрос чтения, виновника ищут в изменениях выше по коду.
Что происходит после commit
По умолчанию commit помечает все объекты сессии устаревшими: SQLAlchemy не знает, что в базе могли сработать триггеры или умолчания, и первое обращение к атрибуту после коммита заново читает строку. Для синхронного кода это лишний SELECT, который легко не заметить. Для асинхронного кода это ошибка MissingGreenlet: неявный запрос из обращения к атрибуту в async невозможен. Поэтому фабрику сессий создают с expire_on_commit=False, а когда свежие значения из базы нужны (например, server_default времени создания), их просят явно через session.refresh(obj).
С expire_on_commit=False объекты после коммита остаются с теми значениями, что были в памяти. Это предсказуемо, и это единственное, чего обычно и хотят.
Правила жизни сессии в сервисе
Сессия живёт одну единицу работы: один HTTP-запрос, одно сообщение из очереди, одну итерацию фоновой задачи. Её создают из фабрики sessionmaker(engine, expire_on_commit=False), открывают через with, и with гарантирует закрытие и возврат соединения в пул при любом исходе:
SessionFactory = sessionmaker(engine, expire_on_commit=False)
def confirm_order(order_id: int) -> None:
with SessionFactory() as session:
with session.begin(): # транзакция: commit на выходе, rollback при исключении
order = session.get(Order, order_id)
order.confirm()
session.add(order)
session.begin() как контекстный менеджер снимает вопрос «где commit»: он на выходе из блока, а исключение откатывает всё. В FastAPI сессию выдаёт yield-зависимость, по одной на запрос, как показано в статье про SQLAlchemy во FastAPI.
Чего не делают: не держат сессию в глобальной переменной или в поле синглтона, не делят одну сессию между потоками или задачами (она не потокобезопасна и не рассчитана на параллельные await), не открывают сессию «на всё приложение». Устаревший scoped_session, который привязывал сессию к потоку, в новых сервисах не нужен: явная сессия на единицу работы проще и честнее.
Merge и expunge: когда объект пришёл издалека
Иногда объект приезжает из другой сессии: достали из кеша, десериализовали из сообщения, вернули из фоновой задачи. session.merge(obj) копирует его состояние в объект текущей сессии (загрузив строку, если нужно) и возвращает этот управляемый объект; исходный остаётся detached. Частая ошибка: продолжать работать с исходным объектом, а не с возвращённым.
session.expunge(obj) делает обратное: отвязывает объект от сессии, не удаляя строку. Нужен, когда объект надо отдать за пределы сессии полностью загруженным и быть уверенным, что никто случайно его не сохранит.
Оба приёма редки в сервисе с короткими сессиями, и их появление в коде обычно сигнал, что сессия живёт дольше, чем должна.
Глубже: что видит транзакция и чего не видитрасширенное
Сессия открывает транзакцию при первом запросе (autobegin) и держит её до commit или rollback. Внутри транзакции действует уровень изоляции базы, по умолчанию в PostgreSQL READ COMMITTED: каждый новый запрос видит данные, зафиксированные к его началу. Но объекты в identity map живут по своим правилам: уже загруженный объект не обновляется новыми запросами, пока его не перечитать явно. Отсюда классическая путаница. Два запроса в одной сессии вернули «разные» данные для одной строки: первый загрузил объект, кто-то снаружи зафиксировал изменение, второй запрос прочитал свежую строку из базы, но отдал старый объект из identity map, потому что идентичность строки та же. Данные в объекте остались старыми. Чтобы увидеть свежее, берут session.refresh(obj), select(...).execution_options(populate_existing=True) или делают проверку состояния внутри with_for_update, который и блокирует строку, и перечитывает её; об этом статья про транзакции и блокировки.
Второе следствие: время жизни транзакции равно времени жизни соединения из пула. Сессия, которая сделала первый SELECT, а потом сорок секунд ждёт внешний HTTP, держит соединение и, возможно, блокировки. Внешние вызовы делают до начала транзакции или после её конца, и статья про command side объясняет, как это укладывается в обработчик.
Коротко
- Объект проходит состояния transient, pending, persistent, detached;
inspect(obj)показывает текущее. - Identity map: одна строка — один объект в сессии; повторный запрос не обновляет загруженный объект, для свежих данных нужен
refreshилиpopulate_existing. flushотправляет SQL внутри транзакции,commitделает flush и фиксирует; autoflush выполняется перед каждым запросом, поэтому ошибка вставки может вылететь на чтении.- После
commitобъекты по умолчанию протухают и перечитываются при обращении; в сервисах ставятexpire_on_commit=Falseи просят свежие значения черезrefresh. - Сессия живёт одну единицу работы, открывается через
with, транзакция черезwith session.begin(); её не делят между потоками и задачами. mergeвозвращает управляемую копию чужого объекта,expungeотвязывает объект; оба в коротких сессиях почти не нужны.- Транзакция держит соединение из пула от первого запроса до конца, внешние вызовы в неё не кладут.
Что почитать дальше
- Ленивая загрузка и N+1 — что сессия делает при обращении к связи и когда это дорого.
- Транзакции и блокировки — границы транзакции, savepoint,
FOR UPDATEи версия агрегата. - Типичные грабли —
DetachedInstanceError, сессия между задачами, autoflush в неожиданном месте.