Головна / Статті

Міграція з Prisma на Drizzle: шестимісячний огляд

Шість місяців після переходу стеку PostgreSQL TypeScript від Prisma до Drizzle ORM: реальні тестування, що покращилося відразу, та недоліки, які завдали проблем.

Видавець: REACTAPP.TOP10 хв читання2403 слів

Джерела: 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, якщо стосуються будь-які з цих умов: ви розгортаєте свої проекти на серверлесс- чи edge-платформах, де розмір бандлу та затримка під час запуску безпосередньо впливають на витрати чи користувацький досвід; ви часто пишете складні аналітичні 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 за посиланням — там чесно описані переваги та недоліки з їхньої точки зору, що є більш відвертим підходом, ніж у більшості порівнянь від інших постачальників.