ORM vs Raw SQL: что выбрать?
ORM или raw SQL: сравните безопасность, скорость и контроль, узнайте, почему N+1 бьет сильнее абстракции, и выберите нужный инструмент для каждого запроса.
Для большинства приложений разумный подход по умолчанию — использовать ORM или построитель запросов для 90% рутинных CRUD-операций и писать SQL вручную для небольшого набора отчётных, аналитических и высоконагруженных запросов, где это действительно оправдано. Это не выбор «одно или другое». Главный вопрос — не «что лучше в абстрактном смысле», а «что подходит для этого запроса, этой команде, с учётом этих ограничений». В статье вы найдёте правило принятия решений, сравнительную таблицу и описание одного антипаттерна — N+1 запросов, — который подводит команды значительно чаще, чем накладные расходы на абстракцию. Примеры приведены для стека JS/TS (Prisma, Drizzle, Kysely, Knex), поскольку именно там сегодня принимается большинство подобных решений.
Ключевые выводы
- По умолчанию используйте ORM или построитель запросов для рутинных CRUD-операций, а к raw SQL обращайтесь только для конкретных отчётных, аналитических и высоконагруженных запросов, где это измеримо необходимо — большинство production-приложений в итоге работают с обоими подходами.
- Реальные потери производительности от ORM почти никогда не связаны с самой абстракцией; их причина — паттерн N+1 запросов, когда загрузка списка и связанных данных по одной строке превращает один запрос страницы в сотни обращений к базе данных.
- Проблема N+1 решается жадной загрузкой связей в одном запросе (через
includeв Prisma или черезJOIN) и выборкой только тех столбцов, которые реально отображаются, — а не отказом от ORM. - Построители запросов — Drizzle, Kysely и Knex — это недооценённый третий вариант: запросы в стиле SQL с проверкой типов, полным контролем над генерируемым SQL и без слоя объектного маппинга.
- ORM параметризуют запросы по умолчанию, что устраняет наиболее распространённый вектор SQL-инъекций; безопасность raw SQL целиком зависит от вашей дисциплины при использовании параметризованных выражений.
Что такое ORM и что означает «raw SQL»
ORM (Object-Relational Mapper) — это библиотека, которая отображает таблицы базы данных на объекты вашего языка программирования, позволяя запрашивать данные через вызовы методов и типизированные модели вместо написания SQL-строк вручную. ORM генерирует SQL, отправляет его в базу данных и гидрирует строки результата обратно в объекты, попутно обрабатывая связи, параметризацию и маппинг типов. В мире JS/TS примерами полноценных ORM являются Prisma и TypeORM. Типичное чтение данных в Prisma выглядит так:
// Пользователи вместе с их постами — в одном вызове
const users = await prisma.user.findMany({
where: { posts: { some: { title: { contains: 'test' } } } },
include: { posts: true },
});
// users[0].posts — уже типизированный массив, без ручного преобразования
Raw SQL означает написание текста запроса вручную и его выполнение через драйвер базы данных с последующим преобразованием плоских строк результата в нужную структуру. Вы получаете полный контроль над точным видом запроса, но и ответственность за маппинг тоже лежит на вас. Тот же запрос «пользователи с их постами» через pg требует JOIN и ручной перегруппировки:
const { rows } = await pool.query(
`SELECT u.id, u.name, p.id AS post_id, p.title
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE p.title LIKE $1`,
['%test%'],
);
// Плоские строки -> вложенные объекты пользователей, вручную
const users = Object.values(
rows.reduce((acc, r) => {
acc[r.id] ??= { id: r.id, name: r.name, posts: [] };
if (r.post_id) acc[r.id].posts.push({ id: r.post_id, title: r.title });
return acc;
}, {} as Record<number, { id: number; name: string; posts: any[] }>),
);
Этот reduce и есть настоящая цена raw SQL при реляционных чтениях: база данных возвращает строки, а не объектные графы, и логику объединения и перегруппировки вы пишете каждый раз заново. Плейсхолдер $1 тоже выполняет важную работу — подробнее об этом в разделе про безопасность.
ORM vs raw SQL: сравнение, которое действительно помогает принять решение
Discover how at OpenReplay.com.
Выбор сводится к пяти критериям. Вот как три подхода — полноценный ORM, построитель запросов и raw SQL — соотносятся по каждому из них:
| Критерий | Полноценный ORM (Prisma, TypeORM) | Построитель запросов (Drizzle, Kysely, Knex) | Raw SQL |
|---|---|---|---|
| Защита от инъекций | Параметризация по умолчанию | Параметризация по умолчанию | Безопасен только при использовании параметризованных выражений |
| Скорость разработки (CRUD) | Максимальная — связи и гидрация обрабатываются автоматически | Высокая — SQL-подобный синтаксис, JOIN пишете сами | Минимальная — ручной маппинг |
| Миграции / схема | Первоклассная поддержка (Prisma Migrate, TypeORM) | Встроенные инструменты или парные решения (Drizzle Kit, Knex migrations) | DDL-скрипты вручную |
| Переносимость между СУБД | Высокая — смена провайдера требует минимальных изменений | Средняя — с учётом диалектов | Низкая — SQL привязан к конкретному диалекту |
| Контроль / специфические возможности СУБД | Ограниченный; требуется escape hatch | Высокий — близко к SQL | Полный — любые возможности и хинты |
| Типобезопасность | Сквозная, от схемы | Строгая (Kysely/Drizzle); частичная (Knex) | Отсутствует без дополнительных инструментов |
В части безопасности практическая истина проста: ORM и построители запросов параметризуют значения по умолчанию, что устраняет классический вектор инъекций. Безопасность raw SQL целиком зависит от вашей дисциплины — документация Prisma прямо предупреждает, что использование небезопасного метода с пользовательскими данными открывает возможность для SQL-инъекций, которые могут привести к изменению или удалению данных. Используйте плейсхолдеры ($1, ?) и никогда не интерполируйте пользовательский ввод в строку запроса — тогда raw SQL безопасен; пренебрежёте этим правилом — и нет.
В части миграций и переносимости ORM выигрывает в типовых сценариях. Изменение схемы превращается в версионированную, воспроизводимую миграцию, которая одинаково выполняется во всех окружениях, а смена провайдера базы данных в основном сводится к изменению конфигурации. Raw SQL не даёт ни того ни другого бесплатно: DDL-скрипты вы управляете сами, а запросы привязаны к синтаксису конкретного диалекта. В части контроля raw SQL выигрывает безоговорочно: оконные функции, рекурсивные CTE, специфичные для СУБД хинты для индексов и настройка с помощью EXPLAIN ANALYZE — всё это доступно без борьбы с абстракцией.
Правда о производительности: проблема N+1, а не абстракция
Самая распространённая критика ORM — «они генерируют медленные запросы» — в основном неверна применительно к CRUD и в основном верна для одного конкретного паттерна. Современные ORM генерируют вполне компетентный SQL для повседневных операций чтения и записи. Реальные потери производительности — это проблема N+1 запросов: вы загружаете список из N строк, а затем для каждой строки выполняете ещё один запрос для получения связанных данных, превращая один запрос страницы в N+1 обращений к базе данных.
Вот как выглядит этот антипаттерн — обычно он прячется внутри безобидного цикла:
// 1 запрос для пользователей...
const users = await prisma.user.findMany();
// ...затем ещё N запросов, по одному на каждого пользователя — это и есть ловушка N+1
for (const user of users) {
user.posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
Решение — не отказываться от ORM. Нужно жадно загрузить связанные данные в одном запросе и выбрать только те столбцы, которые реально отображаются:
const users = await prisma.user.findMany({
include: { posts: { select: { id: true, title: true } } },
});
Это сворачивает 1 + N запросов в один. Симптом неисправленного N+1 на уровне пользователя — страница со списком или дашборд, которые быстро работают на тестовых данных и тормозят под production-нагрузкой. Именно такую клиентскую задержку выявляет запись пользовательских сессий, указывая обратно на слой запросов: нужно посчитать запросы и добавить жадную загрузку.
Проверьте генерируемый SQL, прежде чем обвинять инструмент
Прежде чем делать вывод о медлительности ORM, посмотрите, что он реально генерирует. Prisma логирует каждый запрос, если при инициализации клиента указать log: ['query']; Kysely предоставляет метод .compile(), возвращающий SQL и параметры; Drizzle предоставляет метод .toSQL() на запросе для той же цели. Затем выполните EXPLAIN ANALYZE на полученном выводе, чтобы увидеть план, который реально выполняет база данных.
const prisma = new PrismaClient({ log: ['query'] });
// каждый запрос Prisma теперь выводится в лог — считайте их, копируйте в EXPLAIN ANALYZE
Это превращает «ORM работает медленно» из ощущения в проверяемый рабочий процесс: читайте SQL, считайте обращения к базе, проверяйте план.
Примечание об архитектуре движка: одно из наиболее значимых изменений в Prisma 7 — полное удаление движка запросов на Rust в пользу реализации на TypeScript. Новый Query Compiler работает на TypeScript и WebAssembly, что устраняет шаг кросс-языковой сериализации и ускоряет выполнение запросов; поскольку движок больше не зависит от нативного бинарного файла, Prisma теперь можно использовать в средах с поддержкой JavaScript или WASM, таких как Cloudflare Workers, Bun и Deno. По данным Prisma, переписанный движок нередко значительно быстрее там, где это важнее всего — на больших и сложных запросах, — при этом сопоставим по скорости на простых. Вывод для данного сравнения: дискуссия о движке изменила форму, но не исчезла, и по-прежнему остаётся второстепенной по отношению к количеству запросов. Для подавляющего большинства медленных страниц причина — N+1 и избыточная выборка данных, а не слой абстракции.
Построители запросов: третий вариант, который теряется в бинарном противопоставлении
Формулировка «ORM vs raw SQL» скрывает сильную промежуточную альтернативу. Построители запросов — Drizzle, Kysely и Knex — позволяют писать типизированные запросы в стиле SQL с полным контролем над генерируемым SQL и без слоя объектного маппинга, присущего ORM. Kysely — наиболее мощный типобезопасный построитель SQL-запросов для TypeScript, используемый в production компаниями Deno, Maersk и Cal.com; он рассчитан на современный TypeScript и не имеет накладных расходов в рантайме. Это тонкий слой абстракции над SQL, ориентированный на привычность через именование и структуру, а также на предсказуемость через компиляцию 1:1.
const users = await db
.selectFrom('users')
.innerJoin('posts', 'posts.author_id', 'users.id')
.select(['users.id', 'users.name', 'posts.title'])
.where('posts.title', 'like', '%test%')
.execute();
Структура отражает SQL, но имена столбцов и таблиц проверяются по схеме на уровне типов. Как сформулировано в документации Kysely, построитель запросов даёт полный контроль над SQL, позволяя TypeScript выявлять ошибки на ранних этапах — ещё до запуска кода. Knex — давно зарекомендовавший себя вариант, работающий с Postgres, MySQL, SQLite и другими СУБД, однако стоит учитывать ограничения его типизации: согласно собственной документации, поддержка TypeScript реализована по принципу best-effort, не все паттерны использования поддаются проверке типов, а отсутствие ошибок типов не гарантирует корректность генерируемых запросов — поэтому написание тестов рекомендуется даже при использовании TypeScript. Для нового TS-проекта, которому нужна типобезопасность без тяжеловесности ORM, Kysely или Drizzle — более сильный выбор.
Правило принятия решений по сценарию
Подбирайте инструмент под конкретную задачу, а не выбирайте один для всей кодовой базы:
- Простые и умеренно сложные CRUD-операции, командная разработка, развивающаяся схема → ORM. Вы получаете параметризованные запросы, версионированные миграции, загрузку связей и сквозную типизацию. Это покрывает большую часть кода приложения.
- Отчётность, аналитика, массовые операции или измеренный горячий путь → raw SQL, представления или хранимые процедуры. Когда нужны оконные функции, сложная агрегация или вручную настроенные планы, проверенные через
EXPLAIN ANALYZE, — пишите SQL напрямую. - Нужна типобезопасность и контроль над SQL без ORM → построитель запросов. Drizzle или Kysely дают типизированные запросы в стиле SQL с предсказуемым выводом и без скрытого поведения N+1.
Гибридный подход, к которому приходит большинство production-приложений
На практике зрелые кодовые базы не выбирают одну сторону — они используют ORM для основной части работы и оставляют escape hatch для raw SQL на остальное. Prisma делает это явным через TypedSQL — функциональность Prisma ORM, которая позволяет писать raw SQL запросы в полностью типобезопасной манере. Вы пишете SQL в файле .sql и вызываете его через сгенерированную типизированную функцию:
import { getUsersByAge } from './generated/prisma/sql';
const users = await prisma.$queryRawTyped(getUsersByAge(18, 30));
Это preview-функциональность: её включают через previewFeatures = ["typedSql"] в блоке генератора, и поскольку она статически типизирована, она может не справляться с некоторыми сценариями, например с динамически формируемыми WHERE-условиями — для них по-прежнему нужны нетипизированные raw-методы. Prisma формулирует этот рабочий процесс именно так, как описано в данной статье: высокоуровневая абстракция для большинства запросов плюс типобезопасный escape hatch для случаев, когда SQL нужно составить вручную. Именно поэтому современный ответ на вопрос «ORM или raw SQL» обычно звучит так: «ORM (или построитель запросов), который позволяет при необходимости перейти к raw SQL».
Заключение
Используйте ORM или построитель запросов как основной инструмент для повседневных CRUD-операций, обращайтесь к raw SQL для запросов, где это измеримо оправдано, и рассматривайте оба подхода как слои единой стратегии доступа к данным, а не как конкурирующие лагеря. Конкретный следующий шаг для любого существующего проекта: включите логирование запросов, найдите эндпоинты со списками и дашбордами, посчитайте количество запросов на один запрос страницы и устраните N+1 с помощью жадной загрузки и более точной выборки столбцов — этот единственный проход, как правило, даёт больший прирост производительности, чем любой переход с ORM на SQL или обратно.
Часто задаваемые вопросы
В чём разница между ORM и построителем запросов?
ORM отображает таблицы базы данных на объекты и гидрирует результаты запросов в типизированные модели, автоматически обрабатывая связи и объектный маппинг, тогда как построитель запросов генерирует типизированный SQL, но возвращает обычные строки без слоя объектного маппинга. Prisma и TypeORM — это ORM; Drizzle, Kysely и Knex — построители запросов. Построители запросов находятся ближе к SQL, давая больше контроля над генерируемым запросом при меньшей абстракции и без скрытого поведения при загрузке связей.
Защищает ли ORM от SQL-инъекций?
Да, при обычном использовании. ORM и построители запросов параметризуют значения по умолчанию, передавая пользовательский ввод как связанные параметры, а не интерполируя его в строку запроса, — это устраняет классический вектор инъекций. Исключение составляют небезопасные методы для raw-запросов: документация Prisma предупреждает, что использование небезопасного метода с пользовательскими данными открывает возможность для SQL-инъекций. Безопасность raw SQL целиком зависит от вашей дисциплины при использовании плейсхолдеров вида $1 или ?.
Быстрее ли raw SQL, чем ORM?
В большинстве реальных случаев — нет. Современные ORM генерируют компетентный SQL для повседневных CRUD-операций, а типичный разрыв в производительности обусловлен количеством запросов, а не абстракцией. Основная причина медленных страниц — паттерн N+1, когда загрузка списка и связанных данных по одной строке создаёт сотни обращений к базе данных. К raw SQL стоит обращаться для отчётности, аналитики и измеренных горячих путей, где действительно нужны вручную настроенные планы выполнения.
Как увидеть реальный SQL, генерируемый ORM?
Каждый инструмент предоставляет прямой доступ к генерируемому SQL. Prisma логирует каждый запрос, если при инициализации клиента включить опцию логирования запросов. Kysely предоставляет метод compile, возвращающий строку SQL и её параметры. Drizzle предоставляет метод toSQL на запросе для той же цели. Получив SQL, выполните EXPLAIN ANALYZE в PostgreSQL, чтобы проверить план выполнения и убедиться, что индексы используются ожидаемым образом.
Можно ли писать raw SQL внутри проекта с ORM?
Да, и это распространённый паттерн в production. Большинство ORM предоставляют escape hatch для raw SQL наряду с генерируемыми запросами. Prisma предлагает TypedSQL — preview-функциональность, включаемую флагом typedSql, которая позволяет писать SQL в .sql-файле и вызывать его через сгенерированную полностью типизированную функцию. Поскольку она статически типизирована, она не поддерживает динамически формируемые WHERE-условия — для них по-прежнему нужны нетипизированные методы raw-запросов.