SQLAlchemy 2.0: что сломалось и как переписать старый код
В SQLAlchemy 2.0 исчез Query.get, изменился select(), а сессии стали строже к неявным запросам. Разбираем, какие вызовы придётся переписать и как перевести проект без переписывания с нуля.

SQLAlchemy 2.0 вышла в январе 2023 года и сломала привычки, которые копились с версии 1.3. Классический session.query(User).filter_by(id=1).first() формально ещё работает через legacy-режим, но новый рекомендованный стиль — select() плюс session.execute(), и часть старых конструкций теперь выдаёт предупреждения о removal. Если вы поднимаете проект с 1.4 или, тем более, с 1.3, миграция — это не «поменять номер в requirements», а серия правок в каждом месте, где вы обращаетесь к БД. Разберём, что именно изменилось и как переписать код по частям, а не одним пугающим коммитом.
Речь про переход на SQLAlchemy 2.0.x. Инвалидация кеша, миграции схемы через Alembic и асинхронный движок — отдельные темы, здесь их не трогаем, кроме мест, где они напрямую пересекаются со сменой API.
Что реально изменилось
Ключевых сдвигов четыре, и каждый бьёт по своему типу кода.
- Единый стиль запросов.
session.query()объявлен legacy. Основной путь — конструкцияselect(), которую исполняетsession.execute()илиsession.scalars(). - Query.get исчез из нового стиля. Вместо
session.query(User).get(1)—session.get(User, 1). Методgetна объекте Query в 2.0 удалён. - Декларативная база по-новому. Старый
declarative_base()ещё существует, но рекомендованный способ — наследование отDeclarativeBaseс типизированнымиMapped[...]иmapped_column(). - autocommit удалён полностью. Режим
autocommitу Engine и Session, который в 1.4 был deprecated, в 2.0 отсутствует. Транзакциями управляете явно.
Главная ловушка миграции не в том, что что-то перестаёт работать сразу. Опаснее код, который в 1.4 работает с предупреждением, а в 2.0 меняет поведение молча — например, обращения к атрибуту после закрытия сессии.
Промежуточная ступень: режим будущего в 1.4
SQLAlchemy 1.4 задумана как мост. Она умеет работать в двух режимах, и вы можете включить будущее поведение флагом, не прыгая сразу на 2.0:
from sqlalchemy import create_engine
# 1.4.x: future=True включает поведение 2.0 для движка
engine = create_engine("postgresql+psycopg2://localhost/shop", future=True)
Если проект большой, разумный план — сначала обновиться до последней 1.4.x, включить future=True, прогнать тесты и вычистить все предупреждения о deprecation. Только после этого поднимать до 2.0. Так вы разложите правки на два шага вместо одного взрыва.
Переписываем запросы
Самый частый кусок кода в проекте — выборки. Ниже пары «было — стало» для типичных случаев. Проверялось на 2.0.x.
Простая выборка
from sqlalchemy import select
# Было (1.x стиль, в 2.0 legacy):
users = session.query(User).filter(User.is_active == True).all()
# Стало:
stmt = select(User).where(User.is_active == True)
users = session.scalars(stmt).all()
Обратите внимание: session.execute(stmt) вернёт объекты Row (кортежи), и вам придётся доставать row[0] или использовать .scalars(). Метод session.scalars() сразу отдаёт сами ORM-объекты — для выборки одной сущности это то, что нужно.
Получение по первичному ключу
# Было:
user = session.query(User).get(user_id)
# Стало:
user = session.get(User, user_id)
Первый результат и его отсутствие
# Было:
user = session.query(User).filter_by(email=email).first()
# Стало:
stmt = select(User).where(User.email == email)
user = session.scalars(stmt).first()
# Если ожидаете ровно одну запись — есть строгий вариант:
user = session.scalars(stmt).one_or_none()
Джойны и агрегаты
from sqlalchemy import select, func
# Количество заказов на активного пользователя
stmt = (
select(User.email, func.count(Order.id))
.join(Order, Order.user_id == User.id)
.where(User.is_active == True)
.group_by(User.email)
)
# Здесь результат — кортежи, scalars() не подойдёт:
for email, order_count in session.execute(stmt):
print(email, order_count)
Правило простое: одна сущность целиком — scalars(); несколько колонок или агрегаты — execute() и разбор кортежа.
Переписываем модели
Модели переписывать не обязательно сразу — старый declarative_base() в 2.0 продолжает работать. Но новый стиль даёт типизацию, которую понимает mypy и IDE. Было так:
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String(255), nullable=False, unique=True)
is_active = Column(Boolean, default=True)
Стало так:
from sqlalchemy import String, Boolean
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
Тип в аннотации теперь несёт смысл: Mapped[int] без Optional означает NOT NULL, а Mapped[Optional[str]] (или Mapped[str | None]) — nullable-колонку. Явный nullable= нужен, только если хотите переопределить это.
Сессии и транзакции
В 2.0 стиль работы с сессией сместился к контекстным менеджерам. Раньше многие писали session.commit() и session.close() вручную и ловили висящие транзакции. Теперь рекомендованный паттерн:
from sqlalchemy.orm import Session
with Session(engine) as session:
with session.begin():
user = User(email="a@example.com")
session.add(user)
# выход из begin() делает commit; исключение — rollback
# выход из Session() закрывает сессию
Отдельно про expire_on_commit. По умолчанию после commit() объекты помечаются как expired, и следующее обращение к их атрибутам вызывает новый запрос. Если сессия к этому моменту закрыта, вы получите DetachedInstanceError. В вебе это классическая причина падений: вернули объект из вью после закрытия сессии.
Если не мигрирует: типичные ошибки
AttributeError: 'Query' object has no attribute 'get'— вы поднялись на 2.0, но оставилиquery(...).get(). Замените наsession.get(Model, pk).ArgumentError: Textual SQL expression ... should be explicitly declared as text(...)— в 2.0 сырые строки SQL нельзя передавать напрямую. Оберните:session.execute(text("SELECT 1")).DetachedInstanceError: Instance ... is not bound to a Session— обращаетесь к атрибуту объекта после закрытия сессии из-за expire_on_commit. Либо держите сессию открытой до конца обработки, либо создавайте сессию сexpire_on_commit=False, понимая последствия для консистентности.TypeError: create_engine() got an unexpected keyword argument 'future'— вы уже на 2.0, где флагfutureне нужен и удалён. Просто уберите его.- Молчаливо пустой результат вместо ошибки — вызвали
session.execute(select(User))и итерируете, ожидая объекты, а получаете кортежи. Используйтеscalars()для выборки сущностей.
Порядок действий и что смотреть в проде
- Обновитесь до последней 1.4.x, не трогая код.
- Включите
future=Trueу движка и, если создаётеsessionmaker, у него тоже. - Прогоните тесты с
python -W error::DeprecationWarning, чтобы предупреждения стали падениями и вы увидели все места. - Перепишите запросы на
select(), заменитеquery.getнаsession.get, оберните сырой SQL вtext(). - Когда предупреждений не осталось — поднимайтесь до 2.0.x и снова гоняйте тесты.
- Модели на
DeclarativeBaseиMapped[...]переводите отдельным этапом: это про типизацию, а не про работоспособность.
В проде после релиза следите за двумя вещами. Первое — всплеск DetachedInstanceError в логах: это неучтённые обращения к атрибутам после commit. Второе — рост числа запросов на страницу: expire_on_commit и ленивые загрузки в новом стиле легко дают N+1, если раньше вы неявно полагались на другое поведение сессии. Поднимите логирование SQL (echo=True на стейджинге) и сверьте количество запросов до и после.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: SQLAlchemy 2.0 Migration Guide (официальная документация), SQLAlchemy ORM Querying Guide
Частые вопросы
У меня проект на SQLAlchemy 1.3, а не 1.4. Можно прыгнуть сразу на 2.0?
session.query() совсем перестал работать в 2.0?
Обязательно ли переписывать все модели на Mapped и DeclarativeBase?
После миграции стали падать DetachedInstanceError, чего раньше не было. Почему?
Что делать, если после select() получаю кортежи вместо объектов модели?
Сырой SQL через session.execute перестал работать. Как быть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.