Эти ошибки не про незнание SQL. Они про то, что ORM добавляет слой со своими правилами, и правила эти проявляются не там, где нарушены. Ниже десять граблей, на которые наступают в каждом втором сервисе, с симптомом, причиной и лекарством. Если симптом знаком, читайте сразу нужный раздел.
Объект пережил сессию
Симптом: DetachedInstanceError: Instance is not bound to a Session; attribute refresh operation cannot proceed. Обычно в сериализаторе ответа или в фоновой задаче.
Причина: сессия закрылась (вышли из with, зависимость завершилась), а объект отдали дальше, и у него либо незагруженная связь, либо протухший после commit атрибут. Обращение к такому атрибуту требует запроса, а сессии нет.
Лекарство: граница сессии равна границе использования объектов модели. Внутри сессии собрать DTO или Pydantic-модель через from_attributes=True и отдать её; загрузить нужные связи стратегией; поставить expire_on_commit=False в фабрике. Для фоновой задачи передавать идентификатор, а не объект.
Одна сессия на все запросы
Симптом: случайные IntegrityError, объекты «из другого запроса» в ответе, рост памяти, в async ошибки о соединении, занятом другой операцией.
Причина: сессия создана один раз в модуле или в поле синглтона и используется всеми обработчиками. Она не потокобезопасна и не рассчитана на параллельные задачи, а её identity map копит объекты до бесконечности.
Лекарство: сессия на единицу работы из sessionmaker, через with; во FastAPI через yield-зависимость. Если кажется, что «нужна общая сессия» для передачи объекта между слоями, это тот же случай, что выше: передавать идентификатор или DTO.
IntegrityError вылетает на SELECT
Симптом: стек ошибки указывает на невинный запрос чтения, а сообщение про нарушение уникальности или внешнего ключа.
Причина: autoflush. Перед запросом сессия сбрасывает накопленные изменения, и среди них объект с дубликатом ключа. Ошибка принадлежит session.add тремя строками выше.
Лекарство: искать виновника выше по коду, а не в запросе. При необходимости обернуть рискованную вставку в begin_nested(), чтобы транзакция осталась жива, или заменить её на on_conflict_do_nothing. Проверка «а есть ли уже такая запись» перед вставкой гонку не закрывает, закрывает её только уникальное ограничение в базе.
Изменили JSON, а база не узнала
Симптом: order.meta["paid_at"] = now выполнилось, commit прошёл, в базе старое значение.
Причина: SQLAlchemy отслеживает присваивание атрибуту, а не изменение объекта внутри атрибута. Словарь поменяли на месте, атрибут остался тем же объектом, ORM считает его чистым. Проверено на SQLAlchemy 2.1: история изменений у обычной JSONB-колонки после правки ключа пуста.
Лекарство: MutableDict.as_mutable(JSONB) в объявлении колонки, тогда изменения внутри словаря отслеживаются; либо присваивать новый словарь order.meta = {**order.meta, "paid_at": now}; либо разово пометить flag_modified(order, "meta"). То же для списков и ARRAY.
Наивное время
Симптом: время создания заказа отличается на несколько часов между средами или между двумя сервисами, читающими одну таблицу.
Причина: в колонку timestamptz записали datetime.now() без пояса. Наивное значение база трактует в часовом поясе сессии, который зависит от настроек сервера и драйвера, и один код даёт разные моменты на разных машинах. На стенде с часовым поясом Новосибирска наивные 12:00 легли в базу как 05:00 UTC.
Лекарство: DateTime(timezone=True) в модели, datetime.now(UTC) в коде, и ни одного наивного datetime в сервисе. Почему именно так, объясняет статья про дату и время в PostgreSQL.
Забытый commit и rollback при закрытии
Симптом: обработчик отработал без ошибок, данных в базе нет.
Причина: сессия без session.begin(), в которой изменения сделаны, но commit не вызван. При выходе из with сессия откатывает транзакцию молча.
Лекарство: with SessionFactory() as session, session.begin(): как единственная форма записи в сервисе. Тогда commit не забывается, а исключение откатывает всё.
Пустой список в in_
Симптом: запрос select(Order).where(Order.id.in_(ids)) возвращает пусто, хотя заказы есть.
Причина: ids пришёл пустым, и SQLAlchemy честно скомпилировала условие, которое всегда ложно. Ошибка выше: не загрузились идентификаторы из первого запроса или из внешнего сервиса.
Лекарство: проверять пустой список явно там, где он означает «ничего не фильтровать» или «ошибка», а не доверять базе разобраться.
joinedload на коллекцию с limit
Симптом: LIMIT 10 вернул три заказа, или ошибка о необходимости .unique().
Причина: joinedload коллекции умножает строки: заказ с пятью позициями это пять строк, и LIMIT считает строки. SQLAlchemy требует .unique() на результате и предупреждает, но число заказов всё равно будет меньше ожидаемого.
Лекарство: selectinload для коллекций, joinedload только для одиночных ссылок. Подробнее в статье про стратегии загрузки.
Модель как схема ответа
Симптом: в ответе API появилось поле password_hash или внутренний статус; переименование колонки сломало клиентов.
Причина: класс модели SQLAlchemy отдали FastAPI как response_model или сериализовали напрямую. Схема хранения стала контрактом.
Лекарство: Pydantic-модель ответа с явным перечнем полей и from_attributes=True; модель SQLAlchemy не покидает репозиторий. Это же защищает от DetachedInstanceError в сериализаторе. Как устроен контракт ответа, рассказывает статья про валидацию и схемы.
Сессия запроса в фоновой задаче
Симптом: фоновая задача падает с «сессия закрыта» или работает с устаревшими данными; в async ошибки о закрытом соединении.
Причина: сессию из зависимости запроса передали в BackgroundTasks или asyncio.create_task. Ответ ушёл, зависимость закрыла сессию и вернула соединение в пул, а задача продолжает.
Лекарство: задача открывает свою сессию из фабрики и получает на вход идентификаторы. Как устроены фоновые задачи во FastAPI, в статье про фоновые задачи.
Глубже: как отличить граблю ORM от ошибки базырасширенное
Полезный приём при любой непонятной ошибке из слоя хранения: прочитать SQL, который реально ушёл в базу. echo=True локально, а в тесте перехват через before_cursor_execute с печатью statement и parameters. Половина «странностей SQLAlchemy» на этом этапе превращается в понятный SQL с понятной ошибкой: лишний UPDATE от autoflush, SELECT от протухшего атрибута, IS NULL вместо ожидаемого = NULL.
Вторая половина это состояние объектов: sqlalchemy.inspect(obj) показывает, в какой сессии объект (.session), какие атрибуты не загружены (.unloaded), какие изменены (.modified, .attrs.<имя>.history). Если объект detached, а вы ждали persistent, значит сессия закрылась раньше, чем планировали, и дальше вопрос к границам, а не к SQLAlchemy.
И третье: исключения драйвера приходят обёрнутыми в sqlalchemy.exc.* с оригиналом в .orig. Для PostgreSQL у IntegrityError.orig есть sqlstate и diag.constraint_name, по которым обработчик ошибок отличает нарушение уникальности email от нарушения внешнего ключа и отдаёт разные ответы, не разбирая текст сообщения. Как это укладывается в общий обработчик, рассказывает статья про глобальную обработку ошибок.
Коротко
DetachedInstanceError: объект пережил сессию; наружу отдают DTO, связи грузят стратегией,expire_on_commit=False.- Одна сессия на всех: не потокобезопасна и копит объекты; сессия на единицу работы через
with. IntegrityErrorнаSELECTэто autoflush, виновник выше; гонку закрывает ограничение в базе, не проверка перед вставкой.- Правка словаря внутри
JSONBне отслеживается;MutableDict.as_mutable, новый словарь илиflag_modified. - Наивное время трактуется по поясу сессии базы;
DateTime(timezone=True)иdatetime.now(UTC)везде. - Забытый
commitоткатывается молча; всегдаwith session.begin(). in_([])всегда ложно;joinedloadколлекции ломаетLIMIT, для коллекцийselectinload.- Модель SQLAlchemy не схема ответа и не пассажир фоновой задачи: Pydantic наружу, идентификаторы в задачу.
- При непонятной ошибке читать реальный SQL (
echo,before_cursor_execute) и состояние объекта (inspect(obj)); у ошибок драйвера смотреть.orig.
Что почитать дальше
- Сессия и unit of work — состояния объекта и правила жизни сессии, из которых следуют первые три грабли.
- Асинхронный SQLAlchemy — те же грабли в async и
MissingGreenlet. - Глобальная обработка ошибок на Python — как превратить
IntegrityErrorв понятный ответ API.