Повторный SELECT вернул старый объект: populate_existing в SQLAlchemy

Функция дважды выбирает запись через одну Session. Между выборками значение изменяется отдельной SQL-командой, но второй SELECT возвращает старое имя. Запрос действительно дошёл до базы; дело не обязательно в сетевом кэше. ORM узнаёт уже существующий объект по ключу и по умолчанию не перезаписывает его атрибуты каждым повторным чтением.

Повторный запрос и повторное заполнение различаются

Такое поведение защищает локальные изменения объекта и поддерживает одну идентичность в Session. Если каждый SELECT безусловно заменял атрибуты, простой поиск мог бы затереть подготовленную правку. Когда приложению действительно нужно перечитать найденные записи, запросу задают populate_existing=True. Он обновляет состояние соответствующих экземпляров из результата.

В примере изменение выполняется через Core-команду на том же соединении. Это намеренно позволяет показать поведение identity map без вмешательства уровней изоляции и двух независимых транзакций. В настоящем конкурентном сценарии перечитывание также ограничено тем, какие изменения видны текущей транзакции выбранной базы. populate_existing не обходит правила изоляции.

Проверим идентичность и значения отдельно

Понадобится SQLAlchemy. Два чтения вернут один и тот же Python-объект. Только запрос с явным повторным заполнением изменит атрибут. Дополнительно проверим, что запрос по несуществующему ключу остаётся пустым: параметр влияет на заполнение найденных объектов, а не создаёт их.

from sqlalchemy import create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

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

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
from sqlalchemy import update

with Session(engine) as db:
    db.add(Item(id=1, name="before"))
    db.commit()

with Session(engine) as db:
    first = db.get(Item, 1)
    db.connection().execute(update(Item.__table__).where(Item.id == 1).values(name="after"))
    second = db.scalars(select(Item).where(Item.id == 1)).one()
    assert second is first and second.name == "before"
    refreshed = db.scalars(select(Item).where(Item.id == 1)
                           .execution_options(populate_existing=True)).one()
    assert refreshed is first and first.name == "after"
    assert db.scalars(select(Item).where(Item.id == 999)
                       .execution_options(populate_existing=True)).first() is None
    print({"same_instance": first is refreshed, "name": first.name})
    db.commit()
engine.dispose()

Не стирайте неподтверждённую правку

populate_existing заменяет загружаемые атрибуты, включая локально изменённые значения. Перед применением решите, что должно произойти с текущей правкой. Обычный autoflush отправляет изменения до запроса, но отключённый autoflush или нестандартная последовательность операций меняют картину. Явное сохранение, отмена правки или отказ от перечитывания должны быть частью сценария, а не случайным эффектом параметра.

Для одной известной сущности Session.refresh обычно прямее: код явно говорит, какой объект перечитывается. populate_existing удобен, когда уже есть запрос по набору сущностей или нужно одновременно поменять параметры загрузки отношений. expire пригодится, если значение следует загрузить позже при обращении. У этих способов разный момент SQL и разная область действия.

Долгоживущая Session усиливает путаницу: объект может попасть в неё задолго до текущего действия. В web-приложении короткая Session на единицу работы уменьшает число таких ситуаций. При этом она не отменяет необходимость понимать повторное чтение внутри одной сложной команды. Не создавайте новую Session на каждый атрибут только ради свежести.

Для расследования запишите ключ объекта, границу транзакции и факт выполненного SELECT. Затем сравните выборку отдельной колонки с ORM-сущностью. Если колонка новая, а атрибут старый, ищите локальное состояние Session. Если обе выборки видят прежнее значение, проверяйте транзакцию и источник данных. Такой порядок отделяет два разных механизма устаревания.

Базовые границы работы Session показаны в разборе Session на запрос. Здесь фокус на повторном заполнении уже созданного объекта, а не создании соединения для endpoint.

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

Проверьте сценарий обновления и повторного чтения через репозиторий, затем подключите его к маршруту. Бесплатная программа FastAPI даёт практику Session и тестов, на которой удобно увидеть границу памяти ORM и строк базы. Откройте бесплатную программу FastAPI.

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