← назад к разделу

Таблица в PostgreSQL это строки и колонки, а программа на Python работает с объектами и ссылками между ними. Каждый раз, когда строку превращают в объект и обратно, кто-то пишет код перекладывания: достать курсор, собрать словарь, проверить типы, составить UPDATE с нужными полями. ORM берёт эту работу на себя, и SQLAlchemy это ORM, которым пользуется большинство Python-сервисов. Но SQLAlchemy больше, чем ORM, и понимать её слои полезно с первого дня: половина вопросов «почему запрос такой странный» это вопросы о том, на каком слое вы находитесь.

Обязательно

Два слоя: Core и ORM

Core это SQL на Python. Таблицы описываются объектами Table, запросы собираются функциями select(), insert(), update(), и на выходе получается текст SQL с параметрами для конкретной базы. Никаких объектов-сущностей, только строки и колонки, зато полный контроль над запросом.

ORM стоит поверх Core. Класс Python отображается на таблицу, экземпляр класса на строку, атрибут на колонку, а relationship на внешний ключ. Объект загружают, меняют его поля как обычные атрибуты, и ORM сам составляет UPDATE ровно по изменённым колонкам. Этим занимается Session: он помнит все загруженные объекты и в нужный момент синхронизирует их с базой.

Оба слоя используют один движок и один пул соединений, и в одном сервисе их сочетают: запись через ORM, тяжёлые отчёты через Core.

from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session

engine = create_engine("postgresql+psycopg://app:secret@localhost/shop", echo=False)

with Session(engine) as session:
    customer = session.get(Customer, 42)            # ORM: объект
    customer.name = "Анна"
    session.commit()                                 # ORM сам соберёт UPDATE customer SET name = ...

    total = session.scalar(                          # Core-выражение внутри той же сессии
        select(func.sum(Order.total)).where(Order.customer_id == 42)
    )

Движок, пул и драйвер

create_engine не открывает соединение. Он создаёт объект, который знает адрес базы, драйвер и держит пул: соединения открываются при первом обращении и возвращаются в пул после использования. Пул по умолчанию держит пять соединений и разрешает ещё десять сверх того под пиком; эти числа задают явно под нагрузку и под лимит max_connections базы, об этом статья про производительность.

Движок один на процесс и создаётся при старте приложения. В FastAPI его кладут в lifespan, как показано в статье про SQLAlchemy во FastAPI.

Драйвер выбирают в адресе подключения. Для PostgreSQL три рабочих варианта:

АдресДрайверКогда
postgresql+psycopg://psycopg 3синхронный и асинхронный код одним драйвером
postgresql+asyncpg://asyncpgтолько асинхронный код, самый быстрый на чтении
postgresql+psycopg2://psycopg 2старые проекты, нового кода на нём не пишут

echo=True печатает каждый SQL-запрос в лог. Локально это первый инструмент, чтобы увидеть, что ORM сделал на самом деле; в проде его выключают и смотрят метрики.

Стиль 2.0: один способ писать запросы

У SQLAlchemy долгая история, и в сети много кода в старом стиле: session.query(User).filter_by(...), Column вместо mapped_column, неявное автоподключение. Начиная с версии 2.0 основным стал один способ, и текущая версия 2.1 его продолжает:

from sqlalchemy import select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

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

stmt = select(Customer).where(Customer.name.like("А%")).order_by(Customer.id)
customers = session.scalars(stmt).all()

Три признака нового стиля: модели объявлены через Mapped[...] с аннотациями, запросы строятся функцией select() и выполняются через session.execute или session.scalars, транзакции открываются явно и живут внутри with. Если в проекте встречается session.query, это не ошибка, оно работает, но новый код пишут одинаково, иначе анализатор типов не видит, какие объекты возвращает запрос.

Когда ORM помогает

ORM окупается там, где есть сущности с поведением и инвариантами: заказ с позициями, который нельзя подтвердить пустым; клиент с адресами. Объект загрузили, вызвали метод, сохранили, и Session сам выяснил, что изменилось. Это и есть unit of work, о нём статья про сессию.

Второе, что даёт ORM, это типы. Mapped[int] читают и SQLAlchemy, и mypy: опечатка в имени поля или присваивание строки в число видны до запуска.

Третье: связи. order.customer.name вместо соединения таблиц руками, с выбором стратегии загрузки, когда данных много. Правда, именно здесь прячется самая дорогая ошибка ORM, N+1, и ей посвящена отдельная статья.

Когда честнее написать SQL

Отчёт с пятью соединениями, оконной функцией и группировкой по неделям ORM умеет, но читать такой код труднее, чем сам SQL, а объекты-сущности там не нужны вовсе. Для чтения с агрегатами берут Core: select(func.date_trunc(...), func.sum(...)) или вовсе text() с параметрами. Результат это строки, не объекты, и этого достаточно.

Массовые операции на миллионы строк тоже не задача ORM-объектов: UPDATE ... WHERE одним выражением быстрее, чем загрузить миллион объектов и поменять каждому поле. SQLAlchemy даёт для этого update() и insert() с пакетами данных, об этом в статье про запросы.

И третье: всё, что специфично для PostgreSQL и чего ORM не выражает (LISTEN/NOTIFY, редкие операторы расширений), пишут через text() без чувства вины. ORM инструмент, а не религия.

Дополнительно: при первом чтении можно пропустить

Глубже: что SQLAlchemy делает за кадром при одном commitрасширенное

Полезно один раз увидеть, из чего состоит простая операция. session.get(Customer, 42) берёт соединение из пула, открывает транзакцию (SQLAlchemy делает это сама при первом запросе, это называется autobegin), выполняет SELECT ... WHERE id = 42 и кладёт объект в identity map сессии. Присваивание customer.name = "Анна" ничего не отправляет в базу: сессия помечает атрибут изменённым. session.commit() сначала делает flush, то есть сравнивает состояние объектов с загруженным и собирает UPDATE customer SET name = %(name)s WHERE id = %(id)s только по изменённым колонкам, затем выполняет COMMIT, затем помечает все объекты устаревшими (с expire_on_commit=True по умолчанию), и соединение возвращается в пул.

Отсюда три следствия, которые объясняют большинство неожиданностей. Запрос в середине сессии видит ваши незафиксированные изменения, потому что перед ним выполняется autoflush. После commit первое обращение к полю идёт в базу заново, и в асинхронном коде это ошибка, поэтому там ставят expire_on_commit=False. И соединение занято всё время от первого запроса до commit или rollback: длинная транзакция с HTTP-вызовом внутри держит соединение из пула и блокировки строк.

Коротко

  • SQLAlchemy это два слоя: Core (SQL на Python, строки) и ORM (классы на таблицы, объекты, Session); в сервисе их сочетают.
  • Движок создаётся один раз на процесс и держит пул соединений (по умолчанию 5 плюс 10 сверх); соединения открываются при первом запросе.
  • Драйвер выбирают в адресе: psycopg для sync и async одним пакетом, asyncpg только для async.
  • Стиль 2.0: Mapped[...] и mapped_column в моделях, select() с session.execute или session.scalars, явные транзакции; session.query это старый стиль.
  • ORM берут для сущностей с поведением и связями; отчёты, массовые операции и редкий SQL пишут через Core и text().
  • Один commit это autoflush, UPDATE по изменённым колонкам, COMMIT и протухание объектов; соединение занято от первого запроса до конца транзакции.

Что почитать дальше