После commit объект перестал читаться: expire_on_commit SQLAlchemy

Функция сохраняет запись, вызывает commit, закрывает Session и возвращает ORM-объект. HTTP-слой пытается прочитать name и получает DetachedInstanceError. До commit значение точно существовало. Причина в том, что успешная фиксация по умолчанию помечает состояние объектов истёкшим: следующее чтение должно получить актуальные данные из базы, а соединять объект с базой уже некому.

Commit меняет состояние атрибутов

expire_on_commit=True помогает не считать прежнее состояние безусловно актуальным после завершения транзакции. Пока Session открыта, чтение истёкшего атрибута способно выполнить SELECT. После закрытия объект отсоединён и загрузка невозможна. Поэтому возврат ORM-экземпляра через несколько слоёв требует понимания того, какие атрибуты уже загружены и когда они могут истечь.

Надёжный вариант для web-команды — построить обычное представление ответа до закрытия Session. Можно явно вызвать refresh после commit, затем скопировать необходимые поля в словарь или DTO. Так последующая JSON-сериализация не пытается неожиданно обратиться к базе. Представление содержит только то, что действительно требуется клиенту.

Воспроизведём ошибку и два решения

Установите SQLAlchemy. Пример использует временную SQLite. Сначала ORM-объект возвращается после закрытия Session и падает на чтении. Затем функция создаёт готовый ответ внутри Session. В конце показан альтернативный expire_on_commit=False для случая, когда сознательно нужен доступ к сохранённому состоянию.

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy.orm.exc import DetachedInstanceError

class Base(DeclarativeBase):
    pass

class Item(Base):
    __tablename__ = "items"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
    detached = Item(name="first")
    db.add(detached)
    db.commit()
try:
    detached.name
except DetachedInstanceError:
    print({"after_close": "DetachedInstanceError"})
else:
    raise AssertionError("expired detached attribute unexpectedly loaded")

def create_response(name):
    with Session(engine) as db:
        item = Item(name=name)
        db.add(item)
        db.commit()
        db.refresh(item)
        return {"id": item.id, "name": item.name}

response = create_response("second")
assert response["name"] == "second" and response["id"] == 2
with Session(engine, expire_on_commit=False) as db:
    retained = Item(name="third")
    db.add(retained)
    db.commit()
assert retained.name == "third"
print({"response": response, "retained": retained.name})
engine.dispose()

Что меняет отключение expire

expire_on_commit=False сохраняет уже загруженные значения после commit. Это бывает удобно для выбранной архитектуры, но не делает их всегда свежими. Другой запрос мог изменить строку позднее. Также настройка не загружает отношения, к которым раньше не обращались. После закрытия Session незагруженная коллекция всё равно потребует соединение и может упасть.

Поэтому не стоит выключать expire только ради исчезновения исключения, не посмотрев место сериализации. Определите, должен ли слой сервиса возвращать ORM-сущности или готовые результаты команды. Для большинства HTTP-ответов небольшая отдельная модель делает зависимость от базы яснее и уменьшает риск выдачи внутренних полей.

Refresh после commit выполняет новое чтение, что важно учитывать в задержке запроса. Иногда нужные поля уже доступны после flush до commit, но клиенту нельзя сообщать успех до успешной фиксации. Выбор последовательности зависит от серверных значений, триггеров и требований к возвращаемому состоянию. Проверяйте на своей базе, какие значения действительно появляются после записи.

В регрессионном тесте обращайтесь к результату именно после выхода из функции репозитория. Проверка item.name внутри with Session успешно загрузит атрибут и случайно скроет проблему. То же относится к отладочному print перед закрытием: он способен изменить наблюдаемое поведение, выполнив нужную загрузку заранее.

В разборе Session на запрос показана общая организация ресурса. Здесь исследуется конкретное состояние объекта после commit и то, как безопасно передать результат за пределы этого ресурса.

Продолжить на практике

Сделайте команду создания и верните из неё отдельный результат, затем проверьте JSON endpoint. Бесплатный курс FastAPI объединяет SQLAlchemy, модели ответа и тесты — это подходящая практика для такой последовательности сохранения. Откройте бесплатную программу FastAPI.

Продолжить на практике