Релиз SQLAlchemy 2.0 стал самым крупным сломом API за всю историю библиотеки. Привычный session.query() из легендарного фасада ORM, который писали миллионы разработчиков на Python с 2006 года, формально переехал в категорию legacy и обзавёлся ярлыком устаревшей конструкции. Замена пришла в виде универсального select() и метода Session.execute(), которые работают одинаково и для Core, и для ORM, и для синхронного, и для асинхронного режима. Старый Query никуда не делся и продолжает работать, но новых функций в него уже не добавляют, а внутри он просто транслирует свои вызовы в новый интерфейс. Команды, которые написали приложение на Query пять-семь лет назад, рано или поздно столкнутся с необходимостью переписать значительные куски кодовой базы, и понимание реальных различий между двумя стилями экономит часы отладки на этом пути.
Зачем разработчики SQLAlchemy вообще пошли на такой болезненный шаг
Главный мотив изменений лежит в области унификации. До версии 2.0 в библиотеке сосуществовали два полностью разных способа писать запросы. Core оперировал объектами select() из модуля sqlalchemy.sql.expression, работал на уровне соединения и возвращал кортежи. ORM использовал Query, привязанный к сессии, и возвращал инстансы маппированных классов. Внутреннее устройство этих двух подсистем сильно различалось, что приводило к дублированию кода, разному поведению одинаковых на вид конструкций и постоянной путанице у новичков, которые не понимали, какой инструмент когда применять.
В 2.0 эти миры объединили. Теперь select() умеет работать и с таблицами, и с маппированными классами, а Session.execute() возвращает универсальный Result, поведение которого предсказуемо в обоих сценариях. Параллельно появилась полноценная поддержка типизации через mypy и Pyright, чего у старого Query не было в принципе - он был слишком динамичным, чтобы статические анализаторы понимали, что именно он возвращает в конкретном случае. Новый интерфейс с самого начала проектировался с оглядкой на аннотации типов, и современные IDE подсказывают доступные методы и атрибуты в результатах гораздо точнее.
Ещё один важный момент - подготовка к асинхронному будущему. AsyncSession, появившийся в 1.4, изначально не поддерживает классический Query, и единственный способ работать с асинхронным ORM это новый стиль через select() и await session.execute(). Те, кто планирует когда-либо перевести проект на asyncio, по сути не имеют выбора в этом вопросе, и чем раньше команда привыкнет к новому API, тем меньше будет переучивания при будущей миграции.
Простой случай выборки всех записей и где скрыт первый подводный камень
Самый базовый запрос на старом стиле выглядит знакомо для любого, кто хоть раз писал на SQLAlchemy:
from app.models import User
users = session.query(User).all()
Возвращается список инстансов класса User, и ничего удивительного тут не происходит. Переписав этот же запрос в стиле 2.0, разработчик сталкивается с первой ловушкой:
from sqlalchemy import select
from app.models import User
stmt = select(User)
result = session.execute(stmt)
users = result.all()
Если запустить этот код, в users окажется список не инстансов User, а объектов Row, каждый из которых содержит один элемент - сам User. То есть для доступа к настоящему объекту придётся писать users[0][0] вместо привычного users[0]. Это сделано из-за унификации с Core, где select() может возвращать произвольный набор столбцов, и Row нужен для единообразия результата.
Правильный паттерн, который теперь стоит закрепить в мышечной памяти, использует scalars():
from sqlalchemy import select
from app.models import User
stmt = select(User)
users = session.scalars(stmt).all()
Или, что эквивалентно по результату:
users = session.execute(stmt).scalars().all()
Метод scalars() разворачивает однопозиционные Row и отдаёт сразу инстансы. Сокращённый вариант session.scalars(stmt) - это просто синтаксический сахар над session.execute(stmt).scalars(), читается компактнее и в новом коде встречается чаще. Запомнить тут нужно одно правило: если в select() передан один объект сущности, всегда используется scalars(). Если же в select() перечислены несколько сущностей или столбцов, тогда execute() возвращает осмысленные Row, и scalars() уже не нужен.
Фильтрация и сортировка в новых терминах
Условия where в новом API записываются через метод where() на самом select-выражении, и это одно из самых заметных изменений в синтаксисе. Старый код выглядел так:
users = session.query(User).filter(User.active == True).filter(User.age > 18).all()
Несколько вызовов filter() через точку объединялись через AND неявно. В новом стиле принято передавать все условия одним вызовом where, через запятую:
from sqlalchemy import select
stmt = select(User).where(User.active == True, User.age > 18)
users = session.scalars(stmt).all()
Запятая между условиями автоматически превращается в AND на уровне SQL. Цепочка из нескольких where тоже работает и даёт идентичный результат, разработчик волен выбирать удобный для себя стиль. Сложные логические выражения собираются через or_ и and_ из основного модуля:
from sqlalchemy import select, or_, and_
stmt = select(User).where(
or_(
User.role == "admin",
and_(User.role == "user", User.verified == True),
)
)
users = session.scalars(stmt).all()
Сортировка переехала из order_by на Query в одноимённый метод на select, и здесь обошлось без сюрпризов:
stmt = select(User).where(User.active == True).order_by(User.created_at.desc())
users = session.scalars(stmt).all()
Метод filter_by() с именованными аргументами тоже доступен на новом select, что приятно для тех, кто привык к компактному синтаксису:
stmt = select(User).filter_by(active=True, role="admin")
users = session.scalars(stmt).all()
Под капотом filter_by ровно так же разворачивается в where с проверками на равенство, никакой магии тут нет.
Получение одной записи и история с get
Привычная связка query().get(id) для поиска по первичному ключу в 2.0 переехала в метод сессии. Старая форма всё ещё работает, но выдаёт предупреждение об устаревании:
user = session.query(User).get(42)
Новый канонический вариант теперь такой:
user = session.get(User, 42)
Это не просто косметическая замена. Session.get() оптимизирован для работы через identity map и не отправляет SQL-запрос, если объект уже находится в сессии. Старый query().get() обладал тем же свойством, и в этом плане поведение идентичное, но новый API делает наличие этой оптимизации более очевидным на уровне сигнатуры метода. Для составных первичных ключей передаётся кортеж или словарь:
versioned = session.get(VersionedItem, (5, 10))
versioned = session.get(VersionedItem, {"id": 5, "version_id": 10})
Извлечение первой записи из произвольной выборки требует выбора между несколькими методами, и тут новичка ждёт развилка. Старый query().first() возвращал None, если ничего не нашлось, и первую строку, если что-то было. В новом API на ScalarResult есть аналог:
stmt = select(User).where(User.email == "Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. ")
user = session.scalars(stmt).first()
Но появились и новые соседи: one(), one_or_none() и scalar_one_or_none(). Метод one() возвращает ровно один результат и бросает исключение, если результатов ноль или больше одного. one_or_none() возвращает один результат или None, но бросает исключение при наличии нескольких. scalar_one_or_none() работает по той же логике, но сразу разворачивает Row до самого инстанса:
stmt = select(User).where(User.email == "Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. ")
user = session.execute(stmt).scalar_one_or_none()
Этот метод особенно удобен при работе с полями, имеющими уникальный индекс - он одновременно достаёт инстанс и страхует от ситуации, когда уникальность нарушена и в базе вдруг оказалось два совпадения. Старый Query такой проверки не делал, и переход на новый API часто выявляет логические ошибки, которые годами жили в коде незамеченными.
Джойны, связи и проблема неявного автоджойна
Джойны в новом API работают через явный метод join() на select, и тут разработчиков ждёт самое крупное поведенческое изменение. Старый Query умел выводить условие соединения из контекста, если в filter сравнивались поля разных таблиц:
users = (
session.query(User)
.join(Address)
.filter(Address.city == "Moscow")
.all()
)
В новом стиле принципы те же, но требуется чуть больше явности при работе со связями:
from sqlalchemy import select
from app.models import User, Address
stmt = (
select(User)
.join(User.addresses)
.where(Address.city == "Moscow")
)
users = session.scalars(stmt).all()
Конструкция select(User).join(User.addresses) использует определённую в модели relationship и автоматически подставляет правильное условие соединения. Если связи между моделями нет или есть несколько возможных путей, понадобится явный onclause:
stmt = (
select(User)
.join(Address, User.id == Address.user_id)
.where(Address.city == "Moscow")
)
Для выбора нескольких сущностей сразу синтаксис тоже изменился. Старая форма с add_entity ушла, теперь все нужные сущности передаются прямо в select:
stmt = select(User, Address).join(User.addresses)
for row in session.execute(stmt):
user, address = row
print(user.name, address.city)
При таком вызове scalars() уже не подходит, потому что в каждой строке два элемента. Распаковка идёт напрямую из Row через индексацию или присваивание кортежа, как в примере выше. Жадная загрузка связанных объектов через joinedload, selectinload и subqueryload работает в новом стиле точно так же, как раньше:
from sqlalchemy.orm import selectinload
stmt = select(User).options(selectinload(User.addresses))
users = session.scalars(stmt).all()
Метод options() переехал с Query на select без изменения логики, и тут переход проходит безболезненно для большинства проектов.
Подзапросы, CTE и места, где новый API превосходит старый
Именно в работе с подзапросами разница между двумя стилями становится особенно заметной. Старый Query умел превращать себя в подзапрос через subquery() или from_self(), но синтаксис был громоздким и непредсказуемым. В 2.0 это делается естественно, потому что select сам по себе является выражением и легко превращается в подзапрос или CTE:
from sqlalchemy import select, func
active_count = (
select(Address.user_id, func.count(Address.id).label("addr_count"))
.where(Address.active == True)
.group_by(Address.user_id)
.subquery()
)
stmt = (
select(User, active_count.c.addr_count)
.join(active_count, User.id == active_count.c.user_id)
.where(active_count.c.addr_count > 3)
)
for user, count in session.execute(stmt):
print(user.name, count)
Common Table Expressions через cte() работают аналогично, и для рекурсивных запросов синтаксис стал намного приятнее, чем был в эпоху Query. От from_self() в новой логике стоит отказаться полностью - в большинстве случаев его заменяет банальное оборачивание select в подзапрос, что и читается лучше, и SQL генерирует более прозрачный.
Ещё одна полезная конструкция, ставшая удобной в 2.0, это exists():
from sqlalchemy import select, exists
subq = select(Address).where(Address.user_id == User.id).exists()
stmt = select(User).where(subq)
users = session.scalars(stmt).all()
Подобный подзапрос на Query тоже писался, но через query.exists() с переключением контекста туда-сюда, что путало даже опытных разработчиков. Теперь exists() это просто метод на любом select-выражении, и логика плоская.
UPDATE и DELETE без обхода через инстансы
Массовые операции обновления и удаления переехали на отдельные функции update() и delete() из основного модуля. Старый Query умел делать это так:
session.query(User).filter(User.active == False).update({"role": "guest"})
session.query(User).filter(User.deleted == True).delete()
В 2.0 канонический вид такой:
from sqlalchemy import update, delete
stmt = update(User).where(User.active == False).values(role="guest")
session.execute(stmt)
stmt = delete(User).where(User.deleted == True)
session.execute(stmt)
session.commit()
Важный нюанс касается синхронизации сессии. По умолчанию массовые операции не обновляют состояние объектов, уже загруженных в identity map, и после такого UPDATE разработчик может удивиться тому, что user.role в коде остался прежним. Решается это параметром execution_options:
stmt = update(User).where(User.active == False).values(role="guest")
session.execute(stmt, execution_options={"synchronize_session": "fetch"})
Значение fetch заставляет SQLAlchemy сначала выбрать первичные ключи затронутых строк, а потом обновить соответствующие инстансы в сессии. Альтернативное значение evaluate пытается применить условие WHERE к загруженным объектам в Python, что быстрее, но работает только для простых условий. Третий вариант False полностью отключает синхронизацию, и его выбирают, когда после массового обновления планируется expire всей сессии или просто заверение транзакции.
Типичные ошибки при переходе и как их распознать на код-ревью
Самая частая ошибка после миграции - забыть про scalars() и получить вместо инстанса кортеж с одним элементом. Симптом всегда один: код, который раньше работал как user.name, после переписывания падает с ошибкой типа AttributeError у Row, потому что обращение пошло к самому Row, а не к развёрнутому объекту. Лечится добавлением scalars() в цепочку вызовов.
Вторая частая проблема возникает при попытке использовать execute() в местах, где раньше был просто query.first() или query.all(). Если в коде после миграции остался вызов вида session.execute(stmt).first(), он вернёт Row, а не объект сущности. Это технически корректно, но часто не то, что хотел разработчик. Правильнее писать session.execute(stmt).scalar_one_or_none() для одиночного результата или session.scalars(stmt).all() для списка.
Третий источник сюрпризов - забытый коммит после UPDATE и DELETE. Старый Query.update() возвращал количество затронутых строк сразу, и многие привыкли видеть число как подтверждение успеха. Новые update() и delete() возвращают объект Result, у которого нужно вызвать rowcount, и без явного commit изменения остаются висеть в транзакции:
from sqlalchemy import update
stmt = update(User).where(User.active == False).values(role="guest")
result = session.execute(stmt)
print(f"Обновлено строк: {result.rowcount}")
session.commit()
Без последней строки изменения откатятся при закрытии сессии, и багу будет сложно поймать, если её не воспроизводят интеграционные тесты с явной проверкой состояния после транзакции.
Четвёртая ловушка - неявный кеш Result. Объект, который возвращает execute, можно итерировать только один раз. Старый Query был многоразовым в этом смысле, и привычка вызвать query.all(), а потом query.count() не вызывала проблем. С новым API после первой итерации Result закрывается, и повторный вызов даст пустой результат либо исключение. Решение простое: либо сразу материализовать через all() и потом работать со списком, либо вызывать execute() повторно с тем же stmt, благо сам stmt многоразовый и переиспользуется свободно.
Стратегия постепенной миграции существующей кодовой базы
Никто в здравом уме не переписывает большой проект с Query на select() за один присест. Разумная стратегия выглядит как поэтапная миграция модуль за модулем, с одновременным запретом нового кода в старом стиле через pre-commit или линтер. SQLAlchemy 1.4 специально поддерживает оба API параллельно, и переходный период можно растянуть на месяцы без потери работоспособности.
Полезный приём - включить deprecation warnings как ошибки в режиме разработки:
import warnings
from sqlalchemy.exc import SADeprecationWarning, RemovedIn20Warning
warnings.simplefilter("error", category=RemovedIn20Warning)
Так любой вызов устаревшего API сразу падает с трейсбеком, и пропустить устаревший код становится почти невозможно. В CI этот режим включают обязательно, в продакшене - нет, чтобы пользователи не страдали из-за пропущенных предупреждений.
Тестовое покрытие до миграции должно быть хотя бы умеренным, потому что без тестов поймать тонкие изменения поведения вроде проблем с identity map или забытой синхронизации сессии практически невозможно. Особенно это касается мест, где старый код полагался на побочные эффекты Query - неявную загрузку связей, автокоммит после .update() в определённых конфигурациях, кеширование результатов в самом объекте запроса. Все эти моменты в новом API ведут себя по-другому, и без тестов поломки всплывут в продакшене через недели после релиза.
Несколько практических рекомендаций для команды, начинающей миграцию:
- начать с самых простых выборок без джойнов и связей, чтобы команда привыкла к scalars() и новому виду where;
- вынести часто используемые запросы в репозитории или классы доступа к данным, тогда смена API затронет только эти классы, а вызывающий код останется прежним;
- для массовых UPDATE и DELETE сразу добавлять явный synchronize_session, чтобы не ловить странности с устаревшими данными в идентичности;
- ввести правило в pull request review, что любой новый код пишется только в стиле 2.0, без исключений;
- в качестве финального шага запустить тесты с включённой типизацией mypy, чтобы убедиться, что новый стиль действительно даёт типобезопасность, ради которой всё затевалось.
Полное переписывание зрелого проекта обычно занимает от двух до шести месяцев календарного времени при условии, что миграцию делают параллельно с основной разработкой. Команды, которые откладывают её до момента, когда старый API физически удалят из библиотеки, рискуют столкнуться с дедлайном, в который не уложатся. SQLAlchemy 2.0 уже стабилизирован и активно развивается, версия 2.1 на подходе, и каждый следующий минорный релиз делает разрыв между двумя стилями ощутимее. Откладывать миграцию имеет смысл только в проектах с короткой остаточной жизнью, всем остальным разумнее заложить переход в дорожную карту на ближайший год.
Перевод на новый стиль это не только смена синтаксиса. Это шанс пересмотреть архитектуру слоя доступа к данным, унифицировать паттерны работы с базой, навести порядок в местах, где старый Query позволял писать запутанный код. После завершения миграции код становится короче и понятнее не благодаря синтаксическому сахару, а благодаря тому, что разработчики вынуждены думать о SQL яснее. И это, пожалуй, главная неочевидная выгода всей затеи с заменой Query на select - не само по себе API стало лучше, а люди, пишущие на нём, начали лучше понимать, что они делают с базой.