· typescript / orm / database
How to Migrate from Prisma to Drizzle ORM: Step-by-Step
Step-by-step Prisma to Drizzle migration: schema conversion, query rewrites, transaction refactoring, and eight gotchas most tutorials skip.
By Ethan · Updated May 26, 2026
2,148 words · 11 min read
Migrating from Prisma to Drizzle is worth it if you’re targeting Cloudflare Workers, D1, or any edge runtime with tight bundle caps. If you’re on Prisma 7 and running a standard Lambda or container deployment, the cold-start gap has shrunk to the point where the switching cost rarely justifies the move. This guide covers how to do it either way — and the eight places where the migration will surprise you.
Tested against [email protected] and @prisma/[email protected].
Who this is for
Developers running Prisma in production who are evaluating or executing a migration to Drizzle. You should be comfortable with TypeScript and have at least a working read of raw SQL — Drizzle’s core API maps directly to SQL and hides less than Prisma’s query engine does.
If you haven’t decided yet whether to migrate, read Prisma vs Drizzle first. That article has the head-to-head. This one assumes you’ve made the call and need to execute.
Why people migrate
Bundle size and cold starts
Prisma historically shipped a Rust binary query engine (~14 MB). Prisma 7 (November 2025) replaced it with a TypeScript/WASM implementation, dropping the bundle to ~1.6 MB. Drizzle’s bundle is ~57–67 KB with no binary dependencies.
One developer’s SaaS saw Vercel Edge cold starts drop from 820 ms to 210 ms after migrating, adding 67 KB vs Prisma’s 3.2 MB install.
The nuance: Prisma 7 closed the gap significantly on Lambda and server deployments. The case for Drizzle on cold-start grounds is strongest at the edge, not in traditional serverless environments.
Cloudflare D1 is a hard blocker
Prisma does not support Cloudflare D1. Drizzle does, with a dedicated adapter. If you’re building on the Cloudflare stack, this isn’t a tradeoff — it’s a requirement.
No codegen
Prisma generates a client on every prisma generate. On a 50-model schema, this adds noticeable CI time and requires a build step before the types are available. Drizzle infers types directly from the schema declaration — no generation step, no intermediate artifacts.
Prerequisites and what to expect
Who this migration suits: solo devs or small teams comfortable with SQL, apps on PostgreSQL, MySQL, or SQLite, teams targeting serverless or edge runtimes.
Who should wait: teams with heavy nested include queries and no SQL fluency (Drizzle’s explicit joins require more code for the same result), apps using MongoDB or MSSQL (Drizzle doesn’t support either), and teams that lean on Prisma Studio as a primary DB GUI.
Effort estimate: a community migration of a 10-month-old API with 19 tables took a single developer roughly 21 hours, with a ~25% reduction in lines of code afterward. Source: GitHub Discussions #3146.
The recommended strategy: do not rip out Prisma on day one. Add Drizzle alongside it, migrate table by table, then remove Prisma at the end. This eliminates big-bang risk.
Step-by-step migration
Step 0: optional — run Drizzle alongside Prisma
The Drizzle-Prisma integration lets you share the Prisma connection and migrate incrementally:
npm i drizzle-orm drizzle-prisma-generator
Add a generator to schema.prisma:
generator drizzle {
provider = "drizzle-prisma-generator"
output = "./drizzle"
}
Run prisma generate. This produces Drizzle schema files derived from your Prisma schema. Query via the $drizzle property:
const user = await prisma.$drizzle.select().from(users).where(eq(users.id, id));
Known limitations: Drizzle’s relational API (db.query.X.findFirst({ with: ... })) is unavailable via this extension, SQLite .values() is unsupported, and prepared statements have partial support. This mode works for incremental migration; plan to cut over completely once all tables are converted.
Step 1: install Drizzle ORM and Drizzle Kit
# PostgreSQL
npm i drizzle-orm pg
npm i -D drizzle-kit @types/pg
# MySQL
npm i drizzle-orm mysql2
npm i -D drizzle-kit
# SQLite
npm i drizzle-orm better-sqlite3
npm i -D drizzle-kit @types/better-sqlite3
For a full Postgres connection and pgvector setup after migration, see How to Set Up Drizzle ORM with Postgres.
Step 2: configure drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql', // 'mysql' | 'sqlite'
schema: './src/schema.ts',
out: './drizzle/migrations',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
Step 3: introspect the existing database — do not skip this
npx drizzle-kit introspect
This generates a Drizzle schema file from your current database tables. This step is mandatory before generating any new migrations. Drizzle Kit diffs against what it thinks is in the database. Without an introspect snapshot as baseline, it has no record of what Prisma already ran — and it will produce duplicate CREATE TABLE statements when you first run generate.
After introspection: commit the generated schema as your baseline. Every subsequent drizzle-kit generate runs from that point forward.
Step 4: convert the schema
Prisma schema:
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
}
Drizzle equivalent (PostgreSQL):
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(), // no @updatedAt equivalent — see Gotcha #3
});
export const posts = pgTable('posts', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
});
Relations are declared separately, not inferred from foreign keys:
import { relations } from 'drizzle-orm';
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
user: one(users, { fields: [posts.userId], references: [users.id] }),
}));
Step 5: rewrite queries
Create / Insert
// Prisma
await prisma.user.create({ data: { email, name } });
await prisma.user.createMany({ data: [{ email: 'a' }, { email: 'b' }] });
// Drizzle — arrays are native, no separate "createMany" method
await db.insert(users).values({ email, name });
await db.insert(users).values([{ email: 'a' }, { email: 'b' }]);
Read
// Prisma
const user = await prisma.user.findFirst({
where: { email },
include: { posts: true },
});
// Drizzle — relational API (requires relations declared above)
const user = await db.query.users.findFirst({
where: eq(users.email, email),
with: { posts: true },
});
// Drizzle — core API (explicit join, no relation definitions required)
const user = await db
.select()
.from(users)
.leftJoin(posts, eq(posts.userId, users.id))
.where(eq(users.email, email))
.limit(1);
// Case-insensitive search
const results = await db
.select()
.from(users)
.where(ilike(users.name, '%al%'))
.offset(0)
.limit(20);
Aggregations
Prisma’s _count, _sum, and _avg helpers do not exist in Drizzle’s relational query API. Use the core API instead:
// Prisma
const agg = await prisma.post.aggregate({ _count: { id: true } });
// Drizzle
import { count } from 'drizzle-orm';
const [{ total }] = await db.select({ total: count() }).from(posts);
For complex aggregations, use type-safe raw SQL:
const [{ revenue }] = await db
.select({ revenue: sql<number>`sum(${orders.amount})` })
.from(orders);
Update
// Prisma
await prisma.user.update({ where: { id }, data: { name } });
// Drizzle
await db.update(users).set({ name }).where(eq(users.id, id));
Delete
// Prisma
await prisma.user.delete({ where: { id } });
// Drizzle
await db.delete(users).where(eq(users.id, id));
Step 6: handle transactions
// Prisma — array form (no Drizzle equivalent)
await prisma.$transaction([
prisma.user.create(...),
prisma.post.create(...),
]);
// Prisma — callback form (this maps cleanly to Drizzle)
await prisma.$transaction(async (tx) => { ... });
// Drizzle — callback form only
await db.transaction(async (tx) => {
await tx.insert(users).values({ email });
await tx.insert(auditLog).values({ action: 'user_created' });
});
Prisma’s array-form transactions have no Drizzle equivalent. Any code using the array form must be refactored into callback form before migration.
Step 7: migrations going forward
Once Prisma is removed:
# Generate a migration file from schema changes
npx drizzle-kit generate
# Apply pending migrations
npx drizzle-kit migrate
# Or push directly to a dev database (no migration file created)
npx drizzle-kit push
Drizzle does not generate down-migrations. If you need rollback capability, you must write it manually.
Gotchas
G1 — Migration history conflict (biggest surprise)
Drizzle Kit has no knowledge of Prisma’s migration history. It diffs against the live database. Run drizzle-kit generate without first running drizzle-kit introspect, and it will attempt to recreate every table from scratch.
Always introspect first, commit the snapshot, then generate. This is the step most migration posts skip, and it’s the one that will stop your CI in its tracks.
Sources: DEV.to migration post, GitHub Discussions #2114.
G2 — Decimal columns come back as strings
Drizzle returns decimal and numeric columns as JavaScript strings to preserve precision beyond what a number can represent. Prisma returns them as numbers. Arithmetic on these values requires explicit conversion:
// Prisma — works directly
product.price * quantity
// Drizzle — convert first
parseFloat(product.price) * quantity
Scan your codebase for arithmetic on any column typed Decimal in Prisma before you cut over.
G3 — No automatic updatedAt
Prisma’s @updatedAt sets the timestamp automatically on every write. Drizzle has no equivalent. You must set it manually in every update call:
await db.update(users)
.set({ name, updatedAt: new Date() })
.where(eq(users.id, id));
Or implement a database-level trigger. Either way, you have to wire it up yourself — Drizzle doesn’t do it for you.
G4 — UUID defaults work differently
Prisma’s @default(uuid()) generates UUIDs at the application layer. In Drizzle, use database-level generation for PostgreSQL:
import { uuid } from 'drizzle-orm/pg-core';
id: uuid('id').primaryKey().defaultRandom(), // calls gen_random_uuid() in PG
Or generate in application code:
import { createId } from '@paralleldrive/cuid2';
id: text('id').primaryKey().$defaultFn(() => createId()),
G5 — Introspection can mis-infer nullable columns
At least one production migration hit a bug where drizzle-kit introspect generated a nullable column for a NOT NULL field, causing runtime type errors. After introspection, audit the generated schema file manually before committing — pay particular attention to columns that should be non-null.
G6 — Cloudflare D1 quirks
If you’re migrating to D1 specifically:
- D1 is SQLite under the hood. No FTS5 full-text search; use Cloudflare Vectorize instead.
- You cannot connect to D1 from a local Node.js script directly — use
drizzle-kitwith the D1 HTTP API for running migrations. - D1 has read replicas that can introduce read-after-write inconsistency. Use
.returning()on inserts to read from the primary. - D1’s 10 GB limit on paid plans and its write-optimized-for-reads design make it unsuitable for logging or event pipelines — pair those with Cloudflare Queues.
If you’re migrating specifically to D1, see Turso vs Cloudflare D1 for a direct comparison of both edge SQLite options.
G7 — Aggregations don’t work in the relational query builder
Drizzle’s db.query.X.findMany({ ... }) relational API does not support COUNT, SUM, AVG, or any aggregate functions. Switch to the core query builder for anything that aggregates:
// This does NOT work
await db.query.orders.findMany({ _sum: { amount: true } }); // Prisma API, not Drizzle
// This works
const [{ total }] = await db
.select({ total: sql<number>`sum(${orders.amount})` })
.from(orders);
G8 — No migration rollback
Drizzle generates up-migrations only. There is no automatic down-migration. Feature requests exist (GitHub Issue #2352) but it isn’t shipped in 0.45.2. Write rollbacks manually if you need them, or structure migrations in a way that’s reversible by design.
Verdict
Migrate if:
- You’re targeting Cloudflare Workers, D1, or any edge runtime with a strict bundle cap. Drizzle’s 67 KB vs Prisma’s 1.6 MB is the relevant comparison here — not server deployments.
- Cold starts are a measured problem at the edge. One published Vercel Edge benchmark shows a 4× improvement post-migration (820 ms → 210 ms, see reference 5).
- Your team knows SQL and prefers a query API that doesn’t abstract away the join.
- You want zero codegen in CI.
- You’re on SQLite/D1 (Prisma doesn’t support it).
Stay on Prisma if:
- You’re on Prisma 7 and running standard Lambda or container deployments. The performance gap no longer justifies a migration for most apps.
- Your team writes heavy nested
includequeries without SQL fluency. Explicit joins require more code for the same result. - You use MongoDB or MSSQL. Drizzle doesn’t support either.
- You rely heavily on
@updatedAt,uuid()defaults, or_count/_sumhelpers. Each requires manual replacement — cost adds up on a large schema.
Post-Prisma 7, the cold-start argument alone no longer closes the case for migration on server deployments. The cleaner reasons to move are Cloudflare D1 support, a preference for SQL-style syntax, or eliminating codegen from your pipeline. For anyone already running a stable Prisma 7 setup on a VPS or container: the switching cost exceeds the performance benefit for most apps.
References
- Drizzle official migration guide from Prisma
- Drizzle Prisma extension
- Drizzle Cloudflare D1 docs
- Community migration thread — 19-table app, 21 h, 25% LoC reduction
- Real SaaS migration (DEV.to) — 820 ms → 210 ms cold start
- Migration history conflict — GitHub Discussions #2114
- Rollback feature request — GitHub Issue #2352
- Drizzle vs Prisma — qualitative comparison (encore.dev)