Przenoszenie z Prismy do Drizzle: Sześciomiesięczny przegląd
Sześć miesięcy po przeniesieniu stacku PostgreSQL TypeScript z Prisma na Drizzle ORM: rzeczywiste testy wydajności, to, co poprawiło się od razu, oraz kompromisy, które przysporzyły problemów.
Źródła: I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.
Mniej więcej osiem miesięcy temu nieustanna wątpliwość dotycząca Prismy w końcu przekształciła się w coś, co wymagało podjęcia działań. Nie chodziło o to, że coś było całkowicie zepsute – opór powoli narastał z upływem czasu, aż stało się niemożliwe go ignorować. Konfiguracja obejmowała kilka małych aplikacji, wszystkie działające na PostgreSQL, napisane w TypeScript, znajdujące się za kilkoma usługami API. Nic niezwykłego ani nowoczesnego. Prisma robiła to, do czego miała służyć. Mimo to ciągle pojawiały się różne irytujące problemy.
Stworzony plik @prisma/client był ogromny. Podczas działania w funkcjach serverless czas rozruchu był wyraźnie dłuższy, ponieważ najpierw trzeba było załadować klienta. Krok prisma generate w pipeline’u CI zaczął wydawać się kosztem płatnym przy każdej fuzji kodu. A gdy potrzebna była surowa zapytanie do czegoś bardziej złożonego, niż mogło to obsłużyć ORM, typ zwracany był jako unknown[], co oznaczało brak jakiejkolwiek bezpieczeństwa typów i konieczność ręcznego przekształcania danych wszędzie.
Ostatecznie podjęto decyzję o przejściu na Drizzle ORM, a migracja została zakończona mniej więcej sześć miesięcy temu. Poniżej znajduje się szczery opis tego procesu.
Dlaczego w ogóle rozważałem Drizzle
Problem wielkości pliku był pierwszym prawdziwym powodem, by traktować Drizzle poważnie. W momencie migracji sam klient Prisma dodawał blisko 14 MB do pakietu wdrażania Lambda, przy czym sam binarny silnik zapytań Rust stanowił już kilka megabajtów z tej kwoty. Punkty końcowe, do których nie dochodziło często, miały opóźnienia startu wynoszące 2 do 3 sekund – taka wartość od razu rzuca się w oczy na panelu monitorowania opóźnień.
Drizzle, w porównaniu, ma rozmiar około 7,4 KB po minifikacji i kompresji gzip, przy czym nie ma żadnych zależności w czasie wykonywania. Brak skompilowanego binaru, brak oddzielnego procesu silnika, brak kroku serjalizacji pomiędzy kodem TypeScript a połączeniem z bazą danych.
Drugim źródłem problemów była etap generowania kodu. Każda zmiana schematu oznaczała konieczność uruchomienia prisma generate, zanim TypeScript mógł rozpoznać nową strukturę. W praktyce prowadziło to do jednego z dwóch negatywnych skutków: albo uruchamiano to ręcznie i czasami zapominało się zapisać zregenerowany kod, albo włączano to do każdego procesu CI, co dodawało 15 do 20 sekund na każdą budowę. Żadna z tych opcji nie była satysfakcjonująca.
Z Drizzle schemat sam w sobie jest po prostu TypeScriptem. Wystarczy edytować plik schematu, a typy są aktualizowane natychmiast – nie ma żadnego etapu generowania pomiędzy nimi, a kompilator od razu reaguje na zmiany.
Trzecim czynnikiem motywującym była sposób obsługi surowego SQL. Prisma traktuje go wyłącznie jako rozwiązanie awaryjne: wywołanie $queryRaw zwraca unknown[], co wymaga albo ręcznego przekształcenia, albo schematu Zod, aby można go było bezpiecznie używać. Z kolei tag szablonu sql w Drizzle pozwala bezpośrednio określić jego strukturę — na przykład sql<{ id: number; total: number }> — i otrzymuje się dokładnie ten typ bez konieczności przekształcania ani dodatkowej warstwy walidacji.
Jak wyglądała migracja
Aby to zilustrować, rozważmy trzy tabele: users, products i orders. W Prismie były one zdefiniowane w jednym pliku 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
}
W Drizzle ten sam schemat staje się plikiem 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] }),
}));
Tak, działa to nieco dłużej. Ale to czysty TypeScript od początku do końca. Twój edytor rozumie każde pole w schemacie, funkcja autodopasowywania działa bezpośrednio z nim, a możesz importować fragmenty schematu prosto do logiki aplikacji.
Plik drizzle.config.ts łączy wszystko w jedno:
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,
});
Uwaga: zawsze ustaw strict: true. Jeśli tego pominiesz, Drizzle może zinterpretować zmianę nazwy kolumny jako „usuń starą kolumnę, utwórz nową” – co w skutku niszczy dane bez żadnych objawów, dopóki nie przejrzysz wygenerowanego SQL i nie zobaczysz, co się stało. Ustawienie strict: true sprawia, że Drizzle zatrzymuje się i prosi o potwierdzenie przy każdej niejednoznacznej zmianie.
Gdy schemat jest już gotowy, generowanie migracji odbywa się za pomocą jednego polecenia:
bunx drizzle-kit generate
To tworzy plik SQL w katalogu ./drizzle/migrations. Można go przejrzeć, sprawdzić, czy spełnia oczekiwania, a następnie go zastosować:
bunx drizzle-kit migrate
Ten plik SQL jest dodawany do systemu kontroli wersji obok schematu. Cała historia migracji składa się z prostego, czytelnego kodu SQL, a nie z formatu logów zrozumiałego tylko za pomocą narzędzi Prismy.
Co poprawiło się od razu
Mniejsze pliki, szybszy start w stanie chłodnym. Było to od razu widoczne i łatwe do zmierzenia. Rozmiar plików naszych funkcji Lambda zmniejszył się o ponad 90 procent. Funkcje, które wcześniej potrzebowały ponad dwóch sekund na start w stanie chłodnym, teraz uruchamiają się w mniej niż 400 ms. Zadania w tle nie odnotowały żadnych istotnych korzyści, natomiast nasze funkcje API znacznie przyspieszyły.
Ręcznie wpisany surowy SQL. Okazało się to przydatne już dosłownie drugiego dnia migracji. Mieliśmy zapytanie raportowe łączące cztery tabele z operacjami agregacji, których konstruktor zapytań Prisma po prostu nie mógł wyrazić w czytelny sposób. Rozwiązanie awaryjne Prismy dla tego typu zapytań wyglądało tak:
// 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
`;
W przypadku Drizzle odpowiednie zapytanie wraca z rzeczywistym typem dołączonym:
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
Nadal piszesz interfejs ręcznie zamiast pozwolić na jego automatyczne wywnioskowanie, ale to niewielka cena w porównaniu z używaniem konwersji unknown we wszystkich częściach kodu.
Drizzle Studio. Uruchomienie bunx drizzle-kit studio uruchamia lokalnego przeglądarkę bazy danych. Jest mniej dopracowany niż Prisma Studio, ale szybki, funkcjonalny i nie wymaga konta Prisma ani połączenia z chmurą. Dla codziennej pracy lokalnej ta niezależność ma swoją wartość.
Czego trzeba było dostosować
Relacje wymagają bardziej wyraźnego łączenia. W Prisma pobranie zamówienia wraz z jego użytkownikiem i pozycjami było tak proste, jak tylko użycie include:
// Prisma - nested include
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: true,
items: {
include: { product: true }
}
}
});
API zapytań relacyjnych w Drizzle daje podobny wynik:
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 }
}
}
});
Ostateczna forma obu zapytań jest podobna. Problem polega na tym, że składnia with działa tylko wtedy, gdy zdefiniowano już wywołania relations() w schemacie, te same, które zostały opisane wcześniej. Gdy po raz pierwszy przechodziliśmy na tę metodologię, jeszcze nie skończyliśmy ich konfiguracji i straciliśmy popołudnie na próbach zrozumienia, dlaczego with nic nie zwraca. Gdy relacje są prawidłowo deklarowane, wszystko działa zgodnie z oczekiwaniami – ale ten początkowy krok konfiguracji jest tym, co Prisma obsługuje za Ciebie automatycznie na podstawie samego schematu, bez konieczności dodatkowej konfiguracji.
Proces seedowania jest teraz zadaniem ręcznym. Prisma posiada wbudowaną konwencję seedowania: wystarczy umieścić plik seed.ts w odpowiednim miejscu, uruchomić prisma db seed, i to wszystko. Drizzle nie ma żadnego równoważnego wbudowanego polecenia. Zamiast tego piszesz zwykły skrypt w TypeScript i sam go uruchamiasz:
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);
A następnie konfigurujesz to w pliku package.json:
{
"scripts": {
"db:seed": "bun run src/seed.ts"
}
}
Nic z tego nie jest trudne, a można argumentować, że jest to bardziej przejrzyste niż domyślne zasady Prismy. Jednak wszelkie istniejące narzędzia lub kroki CI, które bezpośrednio wywoływały prisma db seed, będą musiały zostać przepisane tak, aby zamiast tego wywoływały twoje własne skrypty.
Porównanie obu narzędzi bezpośrednio. Drizzle Studio sprawdza się w swojej roli. Prisma Studio wygląda po prostu lepiej i oferuje więcej: wbudowane kontrolery filtrów, paginację oraz bardziej dopracowany edytor tabel. Drizzle Studio natomiast wydaje się skromniejsze. Po sześciu miesiącach używania Drizzle okazało się, że korzystamy z obu narzędzi mniej, niż się spodziewaliśmy, ponieważ większość rzeczywistej eksploracji danych odbywa się za pośrednictwem dedykowanego klienta bazy danych, niezależnie od używanego ORM.
Jedyny przypadek, w którym Prisma nadal wygrywa
Zapisy nawarstwione. Gdy musisz w jednym kroku dodać zamówienie wraz z jego pozycjami, Prisma radzi sobie z tym sprawnie:
// 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 nie oferuje odpowiednika dla operacji tworzenia nawarstwowego. Zamiast tego musisz sam napisać logikę w ramach wyraźnej transakcji:
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;
});
To oznacza znacznie więcej kodu do napisania i utrzymania. Jeśli twoja aplikacja w dużej mierze polega na takim wzorcu, dodatkowy kod bazowy szybko się nagromadza. Wersja Prismy pozostaje krótsza i bardziej bezpośrednio oddaje intencję. Jeśli operacje mutacji nawarstwione często występują w twoim modelu domenowym, wybór Prismy jest całkowicie uzasadniony.
Decyzja o przejściu
Rozważ przeniesienie się do Drizzle, jeśli dotyczy Ciebie cokolwiek z poniższego: używasz platform serverless lub edge, gdzie rozmiar pliku i opóźnienie przy pierwszym uruchomieniu bezpośrednio wpływają na koszty lub doświadczenie użytkownika; często piszesz złożone zapytania SQL analityczne i chcesz mieć do nich bezpośredni dostęp; Twój zespół biegle posługuje się SQL, więc warstwa abstrakcji nie dodaje większej wartości; lub chcesz, aby migracje były reprezentowane jako zwykłe pliki SQL, które każdy inżynier, niezależnie od znajomości ORM, może przeczytać i przejrzeć.
Pozostań przy Prismie, jeśli: pracujesz nad prototypem lub narzędziem wewnętrznym, gdzie szybka dostawa jest ważniejsza niż redukcja milisekund w czasie działania; Twoje aplikacja wykonywa dużo zapisów i odczytów z głęboko zagnieżdżonymi relacjami; część Twojego zespołu nie czuje się komfortowo przy użyciu surowego SQL i korzysta z deklaratywnego stylu modelowania Prismy; lub już przeniosłeś się na Prisma 7, gdzie historyczny problem rozmiaru pliku jest znacznie mniej poważny.
Tutaj również ma znaczenie czas działania. Prisma 7, wydany pod koniec 2025 roku, całkowicie usunął silnik Rust. Jego obecny rozmiar pliku wynosi około 1,6 MB – wciąż ponad 200 razy większy niż rozmiar pliku Drizzle, ale daleki od 14 MB, które pierwotnie skłaniały ludzi do poszukiwania alternatyw. Jeśli jedynym powodem rozważania zmiany byłby rozmiar pliku, warto sprawdzić, czy ten powód nadal jest aktualny, biorąc pod uwagę to, jak bardzo Prisma się uproszczyło.
Dla zespołu odpowiedzialnego za tę migrację odpowiedź nadal brzmiała twierdząco. Lepsze typowanie z użyciem raw-query oraz brak konieczności generowania kodu same w sobie uzasadniały tę zmianę, niezależnie od oszczędności w rozmiarze pliku. Po sześciu miesiącach decyzja ta okazała się słuszna. Niemniej jednak nie jest to uniwersalnie poprawna odpowiedź – każdy, kto twierdzi, że jeden ORM jest obiektywnie lepszy we wszystkich przypadkach użycia, nie mówi ci prawdy.
Kolejne kroki, jeśli chcesz sam to sprawdzić
Jeśli chcesz wypróbować Drizzle z istniejącą bazą danych, bez konieczności przepisywania całego oprogramowania od zera, narzędzia do introspekcji Drizzle Kit umożliwiają drizzle-kit pull na odczytanie Twojego obecnego schematu i automatyczne utworzenie odpowiadających mu definicji w TypeScript. Dzięki temu masz gotowy punkt wyjścia do pracy, zamiast ręcznie pisać każdą definicję tabeli od początku.
oficjalna dokumentacja Drizzle ORM opisuje szczegółowo narzędzie do budowania zapytań relacyjnych, operator szablonów sql oraz pełny zestaw poleceń do migracji. Warto również przeczytać stronę porównawczą Prisma z Drizzle, dostępną pod adresem tutaj — przedstawia ona obiektywny opis zalet i wad z ich perspektywy, co jest bardziej szczere niż większość porównań dostarczanych przez producentów.