Переход с Prisma на Drizzle: обзор за шесть месяцев
Шесть месяцев после перехода стека PostgreSQL TypeScript с Prisma на Drizzle ORM: реальные тесты производительности, что улучшилось сразу, и те недостатки, которые создали проблемы.
Источники: I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.
Примерно восемь месяцев назад постоянные сомнения относительно Prisma наконец превратились в нечто, что требовало действий. Дело было не в том, что что-то полностью сломалось — просто со временем накапливалась трения, пока это стало невозможно игнорировать. Система состояла из нескольких небольших приложений, все они работали на PostgreSQL, были написаны на TypeScript и располагались за несколькими API-сервисами. Ничего необычного или передового. Prisma выполняла свои функции как следует. Тем не менее продолжали появляться различные надоедливые проблемы.
Сгенерированный файл @prisma/client оказался очень большим. При работе с серверлесс-функциями наблюдались заметные задержки при старте из-за необходимости сначала загрузить клиентскую часть. Шаг prisma generate в CI-пайплайне стал казаться постоянной платой за каждую слияние кода. Кроме того, когда требовался прямой запрос для решения задач, сложнее тех, что мог обработать ORM, тип возвращаемых данных оказывался unknown[], что полностью лишало код типобезопасности и вынуждало выполнять ручные преобразования везде.
В итоге было принято решение перейти на Drizzle ORM, и миграция была завершена примерно шесть месяцев назад. Ниже приведен честный отчет о том, как это происходило.
Почему я вообще рассматривал Drizzle
Проблема размера пакета стала первым реальным поводом серьезно отнестись к Drizzle. На этапе миграции один только клиент Prisma добавлял почти 14 МБ к пакету развертывания Lambda, причем бинарник движка запросов Rust сам по себе составлял несколько мегабайт. У тех эндпоинтов, к которым обращались редко, время запуска составляло 2–3 секунды — такие показатели сразу бросаются в глаза на дашборде задержек.
В сравнении с этим Drizzle после минификации и сжатия занимает около 7,4 КБ и не имеет никаких зависимостей во время выполнения. Нет скомпилированного бинарника, нет отдельного процесса движка, нет этапа сериализации между кодом на TypeScript и подключением к базе данных.
Вторым источником проблем был этап генерации кода. Любые изменения схемы требовали запуска команды prisma generate, прежде чем TypeScript смог бы распознать новую структуру. На практике это приводило к одному из двух негативных результатов: либо вы запускали эту команду вручную и иногда забывали сохранить генерированный клиент, либо включали её в каждую сессию CI, что добавляло от 15 до 20 секунд к времени сборки. Ни один из вариантов не был удовлетворительным.
С Drizzle схема сама по себе представляет собой TypeScript. Достаточно отредактировать файл схемы, и типы мгновенно обновляются — промежуточного этапа генерации нет, и компилятор сразу же учитывает изменения.
Третьим фактором, влияющим на выбор, был способ обработки необработанного SQL-кода. Prisma рассматривает его исключительно как вариант на случай неудачи: вызов $queryRaw возвращает значение типа unknown[], которое затем требует либо ручного преобразования, либо использования схемы Zod для обеспечения безопасности при использовании. В свою очередь, тег шаблона sql в Drizzle позволяет напрямую указать форму данных — например, sql<{ id: number; total: number }> — и в результате получается именно такой тип без необходимости преобразований или дополнительных проверок.
Как выглядел процесс миграции
Для иллюстрации рассмотрим три таблицы: users, products и orders. В Prisma они были определены в одном файле schema.prisma:
model User {
id String @id @default(uuid())
email String @unique
name String
orders Order[]
createdAt DateTime @default(now())
}
model Product {
id String @id @default(uuid())
name String
priceInCents Int
orders OrderItem[]
}
model Order {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
items OrderItem[]
createdAt DateTime @default(now())
}
model OrderItem {
id String @id @default(uuid())
orderId String
order Order @relation(fields: [orderId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
quantity Int
}
В Drizzle та же схема превращается в файл TypeScript:
import { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';\
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const products = pgTable('products', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
priceInCents: integer('price_in_cents').notNull(),
});
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const orderItems = pgTable('order_items', {
id: uuid('id').defaultRandom().primaryKey(),
orderId: uuid('order_id').notNull().references(() => orders.id),
productId: uuid('product_id').notNull().references(() => products.id),
quantity: integer('quantity').notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
user: one(users, { fields: [orders.userId], references: [users.id] }),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));
Да, это занимает немного больше времени. Но это настоящий TypeScript с начала до конца. Ваш редактор понимает каждое поле схемы, автодополнение работает непосредственно с ней, и вы можете импортировать части схемы прямо в логику приложения.
Файл drizzle.config.ts объединяет всё воедино:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/schema.ts',
out: './drizzle/migrations',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
strict: true,
});
Будьте осторожны: всегда устанавливайте strict: true. Если этого не сделать, Drizzle может интерпретировать переименование столбца как «удалить старый столбец и создать новый» — что приводит к незаметному уничтожению данных, пока вы не изучите сгенерированный SQL-код, чтобы понять, что произошло. Включение strict: true заставляет Drizzle остановиться и запросить подтверждение каждый раз, когда изменение неоднозначно.
После настройки схемы генерация миграции выполняется одним командным приказом:
bunx drizzle-kit generate
Это генерирует SQL-файл в папке ./drizzle/migrations. Вы его проверяете, убеждаетесь, что он выполняет ожидаемую функцию, а затем применяете его:
bunx drizzle-kit migrate
Этот SQL-файл добавляется в систему контроля версий рядом со схемой. Вся история миграций представлена в виде простого, читаемого SQL-кода, а не в формате логов, понятном только с помощью собственных инструментов Prisma.
Что улучшилось сразу
Меньшие размеры пакетов, более быстрый запуск функций. Это стало очевидно сразу и легко поддавалось измерению. Размеры пакетов Lambda сократились более чем на 90 процентов. Функции, которые раньше требовали более двух секунд на запуск, теперь загружаются менее чем за 400 мс. Фоновые задачи не получили значительной выгоды от этого, но скорость работы наших API-функций заметно увеличилась.
Чистый SQL, введённый вручную. Этот подход оказался полезным буквально на второй день миграции. У нас был запрос для отчётов, объединяющий четыре таблицы с агрегациями, которые конструктор запросов Prisma просто не мог корректно сформулировать. Альтернативный вариант Prisma для таких запросов выглядел следующим образом:
// Prisma - returns unknown[], no type safety
const revenue = await prisma.$queryRaw`
SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) as total_revenue
FROM products p
JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.id, p.name
ORDER BY total_revenue DESC
`;
С использованием Drizzle аналогичный запрос возвращается с указанием реального типа:
import { sql } from 'drizzle-orm';
interface ProductRevenue {
id: string;
name: string;
totalRevenue: number;
}
const revenue = await db.execute(sql<ProductRevenue>`
SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) AS total_revenue
FROM products p
JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.id, p.name
ORDER BY total_revenue DESC
`);
// revenue.rows typed as ProductRevenue[] at compile-time; validate at runtime if needed
Вы всё равно пишете интерфейс вручную, а не позволяете системе определять его автоматически, но это незначительная плата по сравнению с необходимостью использования преобразований типа unknown по всему кодбазе.
Drizzle Studio. Запуск команды bunx drizzle-kit studio создаёт локальный браузер баз данных. Он менее отточен по сравнению с Prisma Studio, но быстр, функционален и не требует учётной записи Prisma или подключения к облаку. Для повседневной работы локально такая независимость имеет большое значение.
Что потребовало настройки
Для установления связей требуется более явное настройка. В Prisma получение заказа вместе с его пользователем и элементами заказа было таким же простым, как использование конструкции include:
// Prisma - nested include
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: true,
items: {
include: { product: true }
}
}
});
API запросов к связям в Drizzle позволяет достичь похожего результата:
import { db } from './db';
import { orders, users, orderItems, products } from './schema';
const order = await db.query.orders.findFirst({
where: (orders, { eq }) => eq(orders.id, orderId),
with: {
user: true,
items: {
with: { product: true }
}
}
});
Конечная структура обоих запросов схожа. Проблема в том, что синтаксис with работает только после того, как вы определите вызовы relations() в своей схеме, те же самые, что описывались ранее. Когда мы впервые перешли на этот подход, мы ещё не закончили их настройку, и у нас ушел целый день на попытки понять, почему with ничего не возвращает. Как только отношения правильно объявлены, всё работает как ожидается — но этот первоначальный шаг настройки автоматически выполняется Prisma на основе самой схемы, без необходимости дополнительной конфигурации.
Заполнение начальных данных теперь — это ручная операция. Prisma включает в себя встроенную конвенцию для заполнения начальных данных: просто поместите файл seed.ts на нужное место, запустите команду prisma db seed, и всё готово. У Drizzle нет аналогичной встроенной команды. Вместо этого вы пишете обычный скрипт на TypeScript и выполняете его сами:
import { db } from './db';
import { users, products } from './schema';
async function seed() {
await db.insert(users).values([
{ email: 'ana.silva@acme.com', name: 'Ana Silva' },
{ email: 'carlos.reyes@acme.com', name: 'Carlos Reyes' },
]);
await db.insert(products).values([
{ name: 'Starter Plan', priceInCents: 2900 },
{ name: 'Pro Plan', priceInCents: 7900 },
{ name: 'Enterprise Plan', priceInCents: 19900 },
]);
console.log('Seed complete');
process.exit(0);
}
seed().catch(console.error);
И вы настраиваете это в файле package.json:
{
"scripts": {
"db:seed": "bun run src/seed.ts"
}
}
Ничего из этого не является сложным, и, пожалуй, это более прозрачно, чем имплицитные правила Prisma. Однако любые существующие инструменты или шаги CI, которые напрямую вызывали команду prisma db seed, потребуют переписки для вызова вашего собственного скрипта вместо неё.
Прямое сравнение двух студий. Drizzle Studio справляется с задачей. Prisma Studio выглядит более привлекательно и предлагает больше: встроенные контроллеры фильтрации, пагинацию и более совершенный редактор таблиц. В сравнении Drizzle Studio кажется более примитивным. Через шесть месяцев использования Drizzle мы стали пользоваться обоими инструментами реже, чем ожидали, поскольку большая часть работы с данными выполняется через специализированный клиент базы данных, независимо от используемого ORM.
Единственный случай, когда Prisma всё ещё выигрывает
Вложенные операции записи. Когда необходимо вставить заказ вместе со всеми его позициями за один шаг, Prisma справляется с этим эффективно:
// Prisma - nested create in one call
const order = await prisma.order.create({
data: {
userId: user.id,
items: {
create: [
{ productId: starterPlanId, quantity: 1 },
{ productId: proPlanId, quantity: 2 },
]
}
},
include: { items: true }
});
У Drizzle нет аналога для вложенных операций создания. Вместо этого необходимо самостоятельно реализовать логику внутри явной транзакции:
import { db } from './db';
import { orders, orderItems } from './schema';
const newOrder = await db.transaction(async (tx) => {
const [order] = await tx
.insert(orders)
.values({ userId: user.id })
.returning();
await tx.insert(orderItems).values([
{ orderId: order.id, productId: starterPlanId, quantity: 1 },
{ orderId: order.id, productId: proPlanId, quantity: 2 },
]);
return order;
});
Это значительно увеличивает объем кода, который нужно писать и обслуживать. Если ваше приложение в большой степени опирается на такой подход, дополнительный «шаблонный» код быстро накапливается. Версия Prisma остается более краткой и более прямо передает цель операции. Если в вашей модели домена часто используются вложенные мутации, выбор Prisma является совершенно разумным решением.
Решение о переходе
Рассмотрите возможность перехода на Drizzle, если справедливо хотя бы одно из следующих условий: вы развертываете приложение на платформах без серверов или на периферийных узлах, где размер пакета и время запуска оказывают прямое влияние на стоимость или пользовательский опыт; вы часто пишете сложные аналитические SQL-запросы и хотите иметь к ним прямой доступ; ваша команда достаточно хорошо владеет SQL, поэтому слой абстракции не приносит значительной пользы; или вы хотите, чтобы миграции представлялись в виде обычных SQL-файлов, которые сможет прочитать и проанализировать любой инженер, независимо от знакомства с ORM.
Оставайтесь с Prisma, если: вы работаете над прототипом или внутренним инструментом, где скорость развертывания важнее, чем сокращение времени выполнения на несколько миллисекунд; ваше приложение выполняет большое количество операций записи и чтения с глубоко вложенными связями; часть вашей команды менее уверена в работе с чистым SQL и извлекает пользу из декларативного стиля моделирования Prisma; или вы уже перешли на Prisma 7, где проблема с размером пакета стала значительно менее серьезной.
Здесь тоже важен момент времени. Prisma 7, выпущенный в конце 2025 года, полностью устранил движок Rust. Размер его текущего пакета составляет около 1,6 МБ — это всё ещё более чем в 200 раз больше, чем размер пакета Drizzle, но далеко не 14 МБ бинарного файла, который раньше побуждал людей искать альтернативы. Если единственной причиной рассмотрения смены был размер пакета, стоит проверить, сохраняется ли эта причина с учётом того, насколько Prisma сократил свои размеры.
Для команды, ответственной за эту миграцию, ответ оставался положительным. Улучшенные типизации для прямых запросов и отсутствие этапа генерации кода сами по себе оправдывали смену, независимо от экономии на размере. Спустя шесть месяцев решение остаётся актуальным. Тем не менее это не универсально верный ответ — тот, кто утверждает, что один ORM объективно превосходит другой во всех сценариях использования, не говорит вам правду.
Следующие шаги, если вы хотите исследовать это сами
Если вы хотите попробовать Drizzle с уже имеющейся у вас базой данных, не беря на себя обязательство полностью переписать код, инструменты инспекции Drizzle Kit позволяют команде drizzle-kit pull считывать вашу существующую схему и автоматически генерировать соответствующие определения на TypeScript. Это позволяет получить рабочую основу вместо того, чтобы вручную писать определения каждой таблицы с нуля.
Официальная документация Drizzle ORM подробно рассматривает инструмент построения реляционных запросов, оператор шаблонов sql и полный набор команд для миграций. Также стоит прочитать страницу сравнения Prisma с Drizzle, доступную по адресу этот источник — в ней объективно описаны преимущества и недостатки с точки зрения Prisma, что является более честным подходом, чем у большинства сравнений от поставщиков.