psycopg, SQLAlchemy Core и ORM: что выбрать для работы с PostgreSQL
Три способа ходить в PostgreSQL из Python дают разный контроль над SQL и разную цену за удобство. Разбираем, где каждый выигрывает и чем платит.

Один и тот же запрос — «выбрать заказы пользователя за месяц» — можно написать тремя способами: голым SQL через psycopg, декларативным построителем SQLAlchemy Core или через ORM с классами-моделями. Все три работают, все три в проде. Разница в том, сколько SQL вы видите, сколько строк пишете и что ломается под нагрузкой. Ниже — сравнение на конкретных задачах, чтобы выбрать не по вкусу, а по цене вопроса.
Версии, о которых идёт речь: psycopg 3.x (пакет называется psycopg, не путать со старым psycopg2), SQLAlchemy 2.x — в двойке Core и ORM работают через единый стиль select(), старый query()-API считается легаси.
Три уровня абстракции
Проще всего думать о них как о лестнице: чем выше, тем меньше кода и меньше контроля над тем, какой SQL реально уходит в базу.
- psycopg 3 — драйвер. Вы пишете SQL строкой, драйвер подставляет параметры и возвращает кортежи или словари. Между вами и базой почти ничего нет.
- SQLAlchemy Core — построитель запросов. SQL вы не пишете руками, а собираете из Python-объектов:
select(orders).where(...). На выходе всё равно SQL, но переносимый между СУБД и защищённый от синтаксических опечаток. - SQLAlchemy ORM — отображение строк на объекты Python. Вы работаете с
UserиOrder, а SQLAlchemy сама решает, когда и какой запрос выполнить.
Core и ORM — это один и тот же пакет SQLAlchemy и один движок под капотом. ORM надстроен над Core, поэтому смешивать их в одном проекте нормально.
Одна задача, три реализации
Возьмём выборку: заказы пользователя с id 42, дороже 1000 рублей.
psycopg 3
import psycopg
# Подключение; в проде строку берут из переменной окружения
with psycopg.connect("postgresql://app:YOUR_PASSWORD@localhost/shop") as conn:
with conn.cursor() as cur:
cur.execute(
# %s — плейсхолдер параметра, psycopg сам экранирует значения
"SELECT id, amount, created_at FROM orders "
"WHERE user_id = %s AND amount > %s",
(42, 1000),
)
rows = cur.fetchall() # список кортежей
for order_id, amount, created_at in rows:
print(order_id, amount)Никакой магии: вы видите ровно тот SQL, что уйдёт в PostgreSQL. Параметры передаются отдельно от текста запроса — это и есть защита от SQL-инъекций, а не ручное экранирование.
SQLAlchemy Core
from sqlalchemy import create_engine, MetaData, Table, select
engine = create_engine("postgresql+psycopg://app:YOUR_PASSWORD@localhost/shop")
metadata = MetaData()
# Отражаем существующую таблицу из базы
orders = Table("orders", metadata, autoload_with=engine)
stmt = (
select(orders.c.id, orders.c.amount, orders.c.created_at)
.where(orders.c.user_id == 42)
.where(orders.c.amount > 1000)
)
with engine.connect() as conn:
for row in conn.execute(stmt):
print(row.id, row.amount) # доступ по имени колонкиSQL вы не писали, но результат — тот же SELECT. Обратите внимание на драйвер в строке подключения: postgresql+psycopg говорит SQLAlchemy использовать именно psycopg 3.
SQLAlchemy ORM
from sqlalchemy import create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from datetime import datetime
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int]
amount: Mapped[int]
created_at: Mapped[datetime]
engine = create_engine("postgresql+psycopg://app:YOUR_PASSWORD@localhost/shop")
with Session(engine) as session:
stmt = select(Order).where(Order.user_id == 42, Order.amount > 1000)
for order in session.scalars(stmt):
print(order.id, order.amount) # order — полноценный объект OrderЗдесь вы получаете объекты Order, а не кортежи: у них есть методы, связи, они умеют менять состояние и синхронизироваться с базой при commit(). За это удобство и придётся платить — об этом дальше.
Чем каждый платит
| Критерий | psycopg 3 | SQLAlchemy Core | SQLAlchemy ORM |
|---|---|---|---|
| Контроль над SQL | полный | высокий | частичный |
| Строк кода на простой CRUD | больше | средне | меньше |
| Переносимость между СУБД | нет | да | да |
| Риск скрытых запросов | нет | нет | есть (lazy load, N+1) |
| Порог входа | низкий | средний | высокий |
| Прямой доступ к фичам PostgreSQL | полный | хороший | ограниченный |
Главная скрытая цена ORM — запросы, которых вы не писали. Обращение к order.user в цикле по ста заказам может тихо превратиться в сто отдельных SELECT — классическая проблема N+1. В psycopg такого не бывает по определению: нет запроса в коде — нет запроса в базе.
Правило, которое экономит часы отладки: если под нагрузкой в логах базы вдруг стало в разы больше запросов, чем строк с execute в коде — это почти всегда ORM подгружает связи лениво.Когда что выбирать
- psycopg 3 напрямую — когда нужен полный контроль над SQL: сложная аналитика, оконные функции,
COPYдля массовой загрузки, специфичные для PostgreSQL конструкции вродеON CONFLICTили работы с JSONB. Также для разовых скриптов, где тянуть SQLAlchemy незачем. - SQLAlchemy Core — когда запросов много и они разные, но объекты-модели не нужны: ETL, отчёты, миграции данных. Вы получаете защиту от опечаток в SQL и переносимость, не платя за трекинг объектов.
- SQLAlchemy ORM — когда доменная модель богатая, много связанных сущностей и типичный веб-CRUD: создать, прочитать, обновить, удалить с валидацией и бизнес-логикой на объектах. Именно этот сценарий у большинства бэкендов.
Комбинировать нормально: ORM для CRUD и большая часть логики, а тяжёлый аналитический запрос — на Core или на голом SQL через тот же движок. SQLAlchemy это прямо предусматривает.
Что ломается на практике
- N+1 в ORM. Лечится жадной загрузкой связей —
selectinloadилиjoinedloadв опциях запроса. Найти проблему помогаетecho=Trueу движка: SQLAlchemy начнёт печатать каждый SQL в лог. - Забытый commit. В psycopg 3 при выходе из
with connтранзакция коммитится, если не было исключения. В ORMSessionбез явногоsession.commit()изменения не сохранятся — частая ошибка новичков. - Смешение синхронного и асинхронного. И psycopg 3, и SQLAlchemy 2.x умеют async, но синхронный движок в asyncio-коде заблокирует event loop. Для async нужен отдельный движок (
create_async_engine) и async-драйвер. - Пул соединений. В голом psycopg вы управляете соединениями сами; для многопоточного сервиса нужен пул (
psycopg_pool). У SQLAlchemy пул встроен вcreate_engine— не открывайте по соединению на запрос вручную поверх него.
С чего начать в своём проекте
Если пишете бэкенд с моделью данных — берите SQLAlchemy 2.x с ORM, а тяжёлые запросы позже вынесете на Core, не меняя движок. Если это скрипт или аналитика — psycopg 3 достаточно. Первым делом включите echo=True на движке в dev-окружении и посмотрите, какой SQL реально уходит в базу: это лучший способ понять, за что вы платите на каждом уровне. В проде следите за числом запросов на один HTTP-запрос — рост говорит о ленивой загрузке раньше, чем о ней сообщат пользователи.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: psycopg 3 — официальная документация, SQLAlchemy 2.0 Documentation
Частые вопросы
У меня в проекте старый psycopg2, всё сломается при переходе на psycopg 3?
Можно ли использовать SQLAlchemy без ORM, только Core?
Что делать, если ORM генерирует не тот SQL, что я ожидал?
У меня async-приложение на FastAPI, какой вариант подойдёт?
Насколько ORM медленнее голого psycopg?
Нужен ли пул соединений, если у меня один скрипт?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.