coursera_2026_06
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Prerequisites: We validated this guide with Prisma CLI 5.x, @prisma/client 5.x, drizzle-orm 0.30.x, and Node.js 18+. Run npx prisma --version to confirm your CLI version before proceeding. A PostgreSQL database is assumed. Windows users should run shell commands in Git Bash or WSL, as several commands use Unix syntax.

Teams running Drizzle ORM in production sometimes reach a point where team growth, tooling needs, or ecosystem demands push them toward Prisma. Migrating from Drizzle to Prisma against a live production database, without downtime or data loss, requires more than swapping imports. It requires a phased strategy that keeps both ORMs running in parallel until the cutover is validated. This guide provides a concrete decision framework and a step-by-step migration playbook for JavaScript and TypeScript teams who have already weighed the trade-offs and need a safe execution plan.

This is not a "Prisma is better" argument. Drizzle excels in specific contexts, and switching carries real costs. The goal here is pragmatic: if you have decided to migrate, here is how to do it without breaking production.

Readers will walk away with a scorable decision checklist, a schema translation workflow, a parallel-run strategy for incremental query migration, and a production deployment checklist.

How to Migrate from Drizzle to Prisma in Production

  1. Audit your Drizzle schema inventory—catalog every table, relation, enum, custom type, and $inferSelect/$inferInsert usage.
  2. Translate the Drizzle schema to a schema.prisma file, using @@map and @map to preserve existing table and column names.
  3. Validate the translation by running prisma db pull against a staging database and diffing the introspected output.
  4. Baseline Prisma migrations with prisma migrate diff and prisma migrate resolve --applied so Prisma never recreates existing tables.
  5. Install @prisma/client alongside Drizzle and rewrite queries module by module using the parallel-run pattern.
  6. Test each migrated module against cloned production data, asserting identical behavior before proceeding to the next.
  7. Remove Drizzle dependencies, schema files, and type utilities once all queries pass validation.
  8. Deploy to production with prisma migrate deploy in CI/CD, monitor for 48 hours, and keep the pre-migration Git tag for rollback.

Table of Contents

When to Migrate from Drizzle to Prisma (Decision Framework)

Signs It's Time to Switch

Several production realities push teams toward Prisma. The most common is team growth. Prisma's declarative schema file (schema.prisma) acts as a single source of truth that new hires can read without deep TypeScript knowledge. Drizzle's TypeScript-first schema definitions, while powerful, require developers to understand both the ORM's API and the underlying SQL semantics simultaneously. Teams regularly report that onboarding a developer to a Drizzle codebase takes noticeably longer than handing them a .prisma file, though the gap depends on the developer's SQL background.

Prisma Studio, Prisma Migrate's migration history tracking, Prisma Accelerate's connection pooling and edge caching (requires Prisma Data Platform account; pricing applies), and Prisma Pulse's real-time database change streams (requires Prisma Data Platform enrollment; pricing applies) all ship as first-party tools. If a team needs any of these, building equivalents on top of Drizzle costs engineering time.

As schemas grow, Drizzle's explicit relation definitions and join syntax become verbose compared to Prisma's include and nested writes. On the ecosystem side, Auth.js ships Prisma adapters, tRPC patterns are commonly paired with Prisma, and many open-source starter kits use Prisma out of the box.

Signs You Should Stay with Drizzle

Not every team should migrate. Drizzle offers SQL-level control that Prisma abstracts away, which matters on performance-critical paths where developers need to reason about exact query plans. Small teams already comfortable with Drizzle's TypeScript-first approach gain little from switching.

Teams making heavy use of Drizzle's relational query API (db.query) that maps cleanly to their domain may find Prisma's relation loading less flexible for their specific access patterns. Edge runtime deployments present a concrete constraint: Prisma requires a query engine binary that ranges from roughly 3 to 15 MB depending on the target platform (or WASM-based driver adapters, which are a Preview feature as of Prisma 5.x -- confirm production stability for your target Prisma version before relying on these). Many edge runtimes enforce bundle limits under 10 MB, making Prisma's engine binary a hard blocker in those environments. Drizzle's npm package, by contrast, adds under 500 KB to node_modules.

Prisma requires a query engine binary that ranges from roughly 3 to 15 MB depending on the target platform. Many edge runtimes enforce bundle limits under 10 MB, making Prisma's engine binary a hard blocker in those environments.

The Decision Checklist

Score each question. If six or more answers are "yes," migration is likely worth the cost. (This threshold is a heuristic, not a formula -- weigh each factor against your team's specific context.)

  1. Do more than three developers regularly touch the data access layer?
  2. Do new team members take more than a week to feel confident writing Drizzle queries?
  3. Do you need visual database management (Prisma Studio or equivalent)?
  4. Are you building features that would benefit from Prisma Accelerate or Pulse (and are comfortable with the associated platform costs)?
  5. Are relation queries in Drizzle becoming verbose or hard to maintain?
  6. Do you rely on ecosystem tools that ship Prisma adapters (Auth.js, Payload CMS, etc.)?
  7. Is your migration history ad-hoc or manually tracked?
  8. Are you deploying to environments where Prisma's engine binary is not a constraint?
  9. Has your schema grown beyond 20 tables with complex relations?
  10. Is your team spending more time debugging ORM behavior than writing features?

Pre-Migration: Audit and Prepare Your Codebase

Map Your Drizzle Schema Inventory

Before writing any Prisma schema, catalog everything Drizzle manages. Walk through all schema files and document every table, relation, enum, and custom type. Pay special attention to Drizzle-specific patterns that have no direct Prisma equivalent: typeof table.$inferSelect and typeof table.$inferInsert TypeScript type utilities -- search for these across all source files, not just schema files -- as well as custom SQL fragments passed to column defaults, and any use of sql tagged templates inside schema definitions.

Identify Query Hotspots

Search the codebase for all imports from drizzle-orm and the project's db instance module. Categorize every query into three buckets: simple CRUD operations, complex joins or subqueries, and raw SQL. Flag all transaction blocks (db.transaction()) and batch operations separately. Categorize queries this way to set the migration order: simple CRUD migrates first, complex queries last.

Set Up a Migration Branch and Testing Strategy

Create a long-lived feature branch for the migration. Before changing any code, verify that existing integration and end-to-end tests cover critical data paths. If test coverage on the data layer is thin, write tests against the current Drizzle implementation first. These tests become the validation harness for the Prisma rewrite. They should assert on behavior, not ORM internals, so they remain valid after the swap.

Step 1: Translate Your Drizzle Schema to Prisma

Schema Translation Patterns

A typical Drizzle schema defines tables using pgTable and relations using relations(). The equivalent Prisma schema uses model blocks with @relation directives.

// Drizzle schema (schema.ts)
import { pgTable, serial, text, varchar, integer, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const roleEnum = pgEnum('role', ['admin', 'member', 'guest']);

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 255 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  role: roleEnum('role').default('member').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  emailIdx: index('email_idx').on(table.email),
}));

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  authorId: integer('author_id').references(() => users.id),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
// Prisma schema (schema.prisma)
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

enum Role {
  admin
  member
  guest
}

model User {
  id        Int      @id @default(autoincrement())
  name      String   @db.VarChar(255)
  email     String   @unique @db.VarChar(255)
  role      Role     @default(member)
  createdAt DateTime @default(now()) @map("created_at")
  posts     Post[]

  @@index([email], map: "email_idx")
  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  authorId  Int?     @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")
  author    User?    @relation(fields: [authorId], references: [id], onDelete: SetNull)

  @@map("posts")
}

Note the use of @@map and @map to preserve existing database table and column names. Without these, Prisma will expect table names matching the model names (User instead of users), which would break against the existing database.

Note that authorId is declared as Int? (nullable) with onDelete: SetNull to match Drizzle's references(() => users.id) without .notNull(). If your domain requires every post to have an author, use authorId Int (non-nullable) and onDelete: Restrict instead, and ensure the Drizzle schema also enforces .notNull() on the foreign key column.

Also note that the Drizzle posts table uses integer('author_id') for the foreign key column, not serial. Using serial on a foreign key column would create an unnecessary auto-incrementing sequence in PostgreSQL -- foreign key columns should use integer since their values reference the primary key of another table.

Handling Edge Cases

Drizzle custom SQL defaults, such as sql`gen_random_uuid()`, translate to Prisma's @default(dbgenerated("gen_random_uuid()")). Note that gen_random_uuid() is built-in to PostgreSQL 13+; on PostgreSQL versions below 13, the pgcrypto extension must be installed for this function to work. Composite primary keys use Prisma's @@id directive. JSON columns map to Prisma's Json type. Partial indexes are not declarable in Prisma schema syntax. Add them by running prisma migrate dev --create-only to generate an empty migration file, then manually adding the CREATE INDEX ... WHERE ... SQL before running prisma migrate dev.

Composite unique constraints require explicit translation:

// Drizzle composite unique
export const memberships = pgTable('memberships', {
  userId: integer('user_id'),
  orgId: integer('org_id'),
  role: text('role').notNull(),
}, (table) => ({
  unq: unique().on(table.userId, table.orgId),
}));
// Prisma composite unique — note the @@id for the required primary key
model Membership {
  userId Int    @map("user_id")
  orgId  Int    @map("org_id")
  role   String

  @@id([userId, orgId])
  @@unique([userId, orgId])
  @@map("memberships")
}

Introspect to Validate

Run npx prisma db pull against a staging clone of the database, not against the live production database. Ensure DATABASE_URL in your environment points to the staging instance before executing this command. This generates a baseline Prisma schema from the live DDL. Diff the introspected schema against the hand-written translation. Resolve any mismatches -- missing indexes, wrong column types, absent default values -- before proceeding.

Step 2: Adopt Prisma Migrations Without Losing History

Baselining Your Existing Database

The database already has tables, indexes, and constraints that Drizzle migrations created. Prisma must not attempt to recreate them. The baselining workflow marks the current schema state as "already applied."

#!/usr/bin/env bash
set -euo pipefail

# Guard: refuse to run against a URL that does not contain 'staging'
if [[ "${DATABASE_URL}" != *"staging"* ]]; then
  echo "ERROR: DATABASE_URL does not appear to point to a staging instance."
  echo "       Current value: ${DATABASE_URL}"
  echo "       Aborting baseline to protect production data."
  exit 1
fi

# Create the migrations directory and generate a baseline migration
# Windows users: use `mkdir prisma\migrations\0_init` or run in Git Bash / WSL
mkdir -p prisma/migrations/0_init

# Generate SQL that represents the current database state
npx prisma migrate diff \
  --from-empty \
  --to-schema-datamodel prisma/schema.prisma \
  --script > prisma/migrations/0_init/migration.sql

# Mark this migration as already applied (do not execute it)
npx prisma migrate resolve --applied 0_init

# Verify clean migration state
npx prisma migrate status

The migrate diff command compares an empty database to the current schema and generates the DDL that would create it. The migrate resolve --applied command records this migration in Prisma's _prisma_migrations table without executing it. This tells Prisma that the database is already at this state.

Note: prisma migrate resolve will create the _prisma_migrations table if it does not exist. If it already exists from a prior Prisma setup, verify no conflicting entries exist with prisma migrate status first.

Validating Migration State

After baselining, prisma migrate status should report no pending migrations. Run this against a cloned staging database that mirrors production before touching the production instance. Any discrepancy between the staging and production schema will surface here as pending or failed migrations.

Step 3: Rewrite Queries with a Parallel-Run Strategy

The Parallel-Run Pattern

Both Prisma Client and Drizzle can coexist in the same codebase, pointing at the same database. Install @prisma/client and run npx prisma generate alongside the existing Drizzle setup. Migrate queries module by module, keeping function signatures stable so calling code does not change.

First, create the Prisma client singleton to prevent connection pool exhaustion, particularly in serverless or hot-module-reloading environments:

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'warn', 'error']
      : ['warn', 'error'],
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

Then migrate queries module by module:

// data/users.ts — Parallel-run pattern

import { prisma } from '../lib/prisma';
// import { db } from '../lib/drizzle';  // Keep available for rollback
// import { users, posts } from '../schema';
// import { eq } from 'drizzle-orm';

// BEFORE (Drizzle implementation)
// export async function getUserWithPosts(userId: number) {
//   return db.query.users.findFirst({
//     where: eq(users.id, userId),
//     with: {
//       posts: true,
//     },
//   });
// }

// AFTER (Prisma implementation — same function signature)
export async function getUserWithPosts(userId: number) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: { posts: true },
  });

  if (!user) {
    throw new Error(`User not found: ${userId}`);
  }

  return user;
}

The commented-out Drizzle implementation stays in the file during the transition period. If the Prisma version introduces regressions, reverting is a single uncomment. Migrate one module at a time, run tests after each swap, and only proceed when confident.

Both Prisma Client and Drizzle can coexist in the same codebase, pointing at the same database. Migrate queries module by module, keeping function signatures stable so calling code does not change.

Query Translation Cheat Sheet

Operation Drizzle Prisma
Select with where db.select().from(users).where(eq(users.email, email)) prisma.user.findMany({ where: { email } })
Insert with returning db.insert(users).values({ name, email }).returning() prisma.user.create({ data: { name, email } })
Conditional update db.update(users).set({ name }).where(eq(users.id, id)) prisma.user.update({ where: { id }, data: { name } })
Delete db.delete(users).where(eq(users.id, id)) prisma.user.delete({ where: { id } })
Transaction db.transaction(async (tx) => { ... }) prisma.$transaction(async (tx) => { ... }, { timeout: 10000, maxWait: 5000 })
Raw SQL (safe) db.execute(sql`SELECT ...`) prisma.$queryRaw(Prisma.sql`SELECT ... WHERE col = ${value}`)

Important: For raw SQL in Prisma, always use Prisma.sql from @prisma/client to ensure parameterized queries. Do not interpolate user input directly into prisma.$queryRaw`...` tagged template literals -- string interpolation in that form bypasses parameterization and creates SQL injection vulnerabilities. Use prisma.$queryRaw(Prisma.sql`SELECT * FROM posts WHERE status = ${userInput}`) instead.

Handling Transactions and Complex Queries

Drizzle's db.transaction() provides a transaction-scoped client. Prisma's prisma.$transaction() works similarly, but note its default 5-second timeout and 2-second maxWait; set these explicitly for long-running operations: prisma.$transaction(async (tx) => { ... }, { timeout: 10000, maxWait: 5000 }). Raw SQL escapes through $queryRaw in Prisma, replacing Drizzle's sql tagged template.

// Drizzle transaction — balance transfer
async function transfer(fromId: number, toId: number, amount: number) {
  if (amount <= 0) throw new Error('Transfer amount must be positive');

  await db.transaction(async (tx) => {
    // Lock rows to prevent lost updates under concurrent access
    const [from] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${fromId} FOR UPDATE`
    );
    const [to] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${toId} FOR UPDATE`
    );

    if (!from || !to) throw new Error('Account not found');
    if (from.balance < amount) throw new Error('Insufficient funds');

    await tx.update(accounts).set({
      balance: sql`${accounts.balance} - ${amount}`,
    }).where(eq(accounts.id, fromId));

    await tx.update(accounts).set({
      balance: sql`${accounts.balance} + ${amount}`,
    }).where(eq(accounts.id, toId));
  });
}

// Prisma transaction — balance transfer
async function transfer(fromId: number, toId: number, amount: number) {
  if (amount <= 0) throw new Error('Transfer amount must be positive');

  await prisma.$transaction(
    async (tx) => {
      // Lock both rows in consistent order to prevent deadlock and lost updates
      const [from] = await tx.$queryRaw<{ balance: number }[]>(
        Prisma.sql`SELECT balance FROM accounts WHERE id = ${fromId} FOR UPDATE`
      );
      const [to] = await tx.$queryRaw<{ balance: number }[]>(
        Prisma.sql`SELECT balance FROM accounts WHERE id = ${toId} FOR UPDATE`
      );

      if (!from || !to) throw new Error('Account not found');
      if (from.balance < amount) throw new Error('Insufficient funds');

      await tx.account.update({
        where: { id: fromId },
        data: { balance: { decrement: amount } },
      });
      await tx.account.update({
        where: { id: toId },
        data: { balance: { increment: amount } },
      });
    },
    { timeout: 10000, maxWait: 5000, isolationLevel: 'Serializable' }
  );
}

Both versions use SELECT ... FOR UPDATE to acquire row-level locks before modifying balances, preventing lost-update race conditions under concurrent access. The Prisma version also sets an explicit timeout, maxWait, and isolation level. Prisma's increment and decrement operations replace the raw SQL arithmetic needed in Drizzle. Both versions execute within a database transaction, ensuring atomicity.

Step 4: Remove Drizzle and Clean Up

Dependency Removal

Once all queries have been migrated and validated, remove Drizzle dependencies: drizzle-orm, drizzle-kit, and any database driver packages that Prisma now replaces (such as postgres or pg if Prisma manages the connection). Before removing database driver packages, verify no direct usages remain outside of Drizzle:

# Use separate patterns to avoid shell quoting issues across bash/zsh/sh
grep -rE "require\(['\"]pg['\"]|from ['\"]pg['\"]" src/

Delete Drizzle schema files, drizzle.config.ts, and the generated drizzle migrations folder. Update package.json scripts: replace drizzle-kit generate with prisma migrate dev, drizzle-kit push with prisma db push, and drizzle-kit studio with prisma studio.

Type Migration

Replace all instances of InferSelectModel<typeof users>, InferInsertModel<typeof users>, typeof users.$inferSelect, and typeof users.$inferInsert with Prisma's auto-generated types from @prisma/client. Both forms exist in Drizzle codebases and must be found -- InferSelectModel and InferInsertModel are named imports from drizzle-orm, while $inferSelect and $inferInsert are inline type utilities used at type-annotation sites across the codebase. Prisma generates types like User, Post, and input types like UserCreateInput automatically during prisma generate. Run a full TypeScript compilation (tsc --noEmit) to surface any remaining Drizzle references that need updating.

Step 5: Production Deployment Safely

Deployment Checklist

Follow this sequence for production go-live:

  1. Test the full migration against a staging database cloned from production data.
  2. Add npx prisma migrate deploy to the CI/CD pipeline, running before application startup.
  3. Add npx prisma generate to the build step so the Prisma Client matches the schema.
  4. Configure connection pooling. Use PgBouncer in transaction mode or Prisma Accelerate if deploying to serverless environments.
  5. Create a Git tag on the last Drizzle commit (git tag pre-prisma-migration) before merging the migration branch, and keep the branch available for at least two weeks post-migration.
  6. Enable slow query logging in the database and monitor for performance regressions in the first 48 hours.

Rollback Strategy

The key insight enabling safe rollback: the database schema has not changed. Only the ORM client accessing it has changed. If Prisma causes issues, revert to the tagged Drizzle branch and redeploy. The database remains compatible because the migration was an application-layer change, not a DDL change. Note: the _prisma_migrations table will remain in the database; decide whether to drop it (DROP TABLE _prisma_migrations) as part of a full rollback, or retain it if Prisma will be re-introduced.

If the migration introduced Prisma-managed DDL changes (adding columns, indexes, or modifying constraints), prepare reverse migration SQL and test it against staging before deploying the forward migration to production.

The key insight enabling safe rollback: the database schema has not changed. Only the ORM client accessing it has changed. If Prisma causes issues, revert to the tagged Drizzle branch and redeploy.

Common Pitfalls and How to Avoid Them

Enum drift

Prisma manages enums in the schema file and expects them to match the database exactly. If database enums were modified outside of Drizzle's migration system, prisma db pull will capture the actual state, but hand-written schemas may diverge. Always validate with introspection.

Implicit many-to-many

Prisma can create join tables automatically for implicit many-to-many relations. Drizzle requires explicit join tables. If the existing database has explicit join tables, model them explicitly in Prisma as well, using two one-to-many relations. Do not allow Prisma to create a second join table alongside your existing one. Implicit many-to-many in Prisma is only viable when the join table has no extra columns beyond the two foreign keys.

Connection limits

During the parallel-run phase, both Drizzle and Prisma open their own connection pools. This effectively doubles connection usage. Most managed PostgreSQL services cap connections at 20 to 100 depending on plan tier -- check yours with SHOW max_connections; in psql. Set Prisma's pool size via ?connection_limit=N in DATABASE_URL and Drizzle's pool max option so the sum stays below that limit.

Generated column mismatches

Prisma's introspection may not fully capture computed columns, expression-based defaults, or database-level triggers. Review introspected output manually for any column marked as @default(dbgenerated()) and verify the expression matches the actual database definition.

Making Migration a Non-Event

Both ORMs coexist against the same database, queries migrate module by module, and the database schema stays unchanged throughout. The rollback path is always available: redeploy the Drizzle branch.

Teams that have not yet committed to migrating should score themselves against the decision checklist. A score below six suggests the migration cost likely exceeds the benefit. For teams mid-migration, the production deployment checklist provides a concrete sequence to follow.

Test against cloned production data, not synthetic fixtures. Schema translation errors, connection pool exhaustion, and enum mismatches only surface with production data shapes and production concurrency patterns.

coursera_2026_06_footer
SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.