← Back to Blog
Migrating from Prisma to Drizzle: A Practical Guide

When we migrated the Catalyst PSA platform from Prisma to Drizzle ORM, we were taking a calculated risk. Prisma was working fine, but we were hitting performance walls and wanted more control over our SQL. After successfully migrating a large codebase, I can confidently say it was worth it.

Here's everything we learned about migrating from Prisma to Drizzle in a production enterprise application.

Why We Migrated

Prisma is an excellent ORM. We built Catalyst PSA on it and were mostly happy. But as the platform grew, we encountered issues:

Performance Problems

Prisma generates queries that aren't always optimal. We found ourselves writing raw SQL for complex queries, defeating the purpose of an ORM.

// Prisma query that generated suboptimal SQL
const projects = await prisma.project.findMany({
  where: {
    client: {
      industry: 'technology',
    },
    status: 'active',
  },
  include: {
    tasks: {
      where: {
        status: 'pending',
      },
    },
    timeEntries: true,
  },
});

This generated a query with multiple subqueries and joins that were slow on large datasets.

Lack of Control

Prisma abstracts SQL away. Usually good, but sometimes you need fine-grained control. We wanted:

  • Custom join strategies
  • Lateral joins for complex aggregations
  • Partial indexes
  • Query hints for the planner

Prisma didn't give us easy access to these.

Type Safety Concerns

Prisma's generated types are comprehensive but sometimes too broad. This passed type checking but crashed at runtime:

const project = await prisma.project.findUnique({
  where: { id: 'some-id' },
});
 
// TypeScript thinks project.client exists, but it doesn't
// because we didn't include it
console.log(project.client.name); // Runtime error!

Drizzle's types are stricter—if you don't select it, it's not in the type.

Migration Speed

Prisma migrations were slow. With 150+ tables, running prisma migrate dev took minutes. Drizzle's migration generation is nearly instant.

Why Drizzle?

Drizzle offered solutions to all our pain points:

  • Performance: Write SQL-like queries that compile to efficient SQL
  • Control: Full SQL access when needed
  • Type safety: Strict types based on what you actually select
  • Migrations: Fast, simple, SQL-based
  • Bundle size: Smaller than Prisma (important for edge deployments)

Most importantly, Drizzle's API felt natural for developers who understand SQL.

Migration Strategy

Migrating a large codebase required a methodical approach. We couldn't stop development for a big-bang rewrite.

Phase 1: Set Up Drizzle Alongside Prisma (Week 1)

We ran both ORMs in parallel initially:

// database.module.ts
@Module({
  providers: [
    PrismaService, // Keep existing
    DrizzleService, // Add new
  ],
  exports: [PrismaService, DrizzleService],
})
export class DatabaseModule {}

Install Drizzle:

npm install drizzle-orm postgres
npm install -D drizzle-kit

Configure Drizzle:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
export default defineConfig({
  schema: './src/infrastructure/database/schema/*',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Phase 2: Create Drizzle Schema (Week 2-3)

We converted Prisma schema to Drizzle, table by table:

// Prisma schema
model Project {
  id          String   @id @default(uuid())
  tenantId    String
  name        String
  status      String
  clientId    String
  client      Client   @relation(fields: [clientId], references: [id])
  tasks       Task[]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@index([tenantId, status])
}

Becomes Drizzle:

// schema/project.schema.ts
import { pgTable, uuid, varchar, timestamp, index } from 'drizzle-orm/pg-core';
import { clients } from './client.schema';
 
export const projects = pgTable(
  'projects',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    tenantId: uuid('tenant_id').notNull(),
    name: varchar('name', { length: 255 }).notNull(),
    status: varchar('status', { length: 50 }).notNull(),
    clientId: uuid('client_id')
      .notNull()
      .references(() => clients.id),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => ({
    tenantStatusIdx: index('idx_projects_tenant_status').on(
      table.tenantId,
      table.status
    ),
  })
);

We used a script to generate initial Drizzle schemas from Prisma:

// scripts/generate-drizzle-schema.ts
import { DMMF } from '@prisma/generator-helper';
import * as fs from 'fs';
 
function convertPrismaFieldToDrizzle(field: DMMF.Field): string {
  const type = mapPrismaTypeToDrizzle(field.type);
  const constraints = [];
 
  if (field.isId) constraints.push('.primaryKey()');
  if (field.isRequired && !field.hasDefaultValue) constraints.push('.notNull()');
  if (field.default) constraints.push(`.default(${field.default})`);
 
  return `${field.name}: ${type}('${field.name}')${constraints.join('')}`;
}
 
// This saved us days of manual conversion

Phase 3: Migrate Repositories (Week 4-8)

We migrated repositories module by module, starting with low-risk areas:

// Old Prisma repository
@Injectable()
export class ProjectRepository {
  constructor(private prisma: PrismaService) {}
 
  async findById(id: string): Promise<Project | null> {
    const result = await this.prisma.project.findUnique({
      where: { id },
      include: {
        client: true,
        tasks: true,
      },
    });
 
    return result ? this.toDomain(result) : null;
  }
}

Became:

// New Drizzle repository
@Injectable()
export class ProjectRepository {
  constructor(private db: DrizzleService) {}
 
  async findById(id: string): Promise<Project | null> {
    const result = await this.db
      .select()
      .from(projects)
      .leftJoin(clients, eq(projects.clientId, clients.id))
      .leftJoin(tasks, eq(tasks.projectId, projects.id))
      .where(eq(projects.id, id))
      .limit(1);
 
    if (!result[0]) return null;
 
    return this.toDomain({
      ...result[0].projects,
      client: result[0].clients,
      tasks: result.filter((r) => r.tasks).map((r) => r.tasks),
    });
  }
}

Phase 4: Data Migration (Week 9)

We needed to ensure data integrity. Drizzle uses the same database, but column names might differ:

// Prisma uses camelCase, we standardized on snake_case with Drizzle
await db.execute(sql`
  ALTER TABLE projects RENAME COLUMN "clientId" TO "client_id";
  ALTER TABLE projects RENAME COLUMN "createdAt" TO "created_at";
  ALTER TABLE projects RENAME COLUMN "updatedAt" TO "updated_at";
`);

We did this in a maintenance window with zero downtime using view trick:

-- Create view with old column names
CREATE VIEW projects_legacy AS
SELECT
  id,
  tenant_id as "tenantId",
  client_id as "clientId",
  created_at as "createdAt",
  updated_at as "updatedAt"
FROM projects;
 
-- Prisma queries the view while we migrate code
-- Once all code migrated, drop the view

Phase 5: Remove Prisma (Week 10)

After all repositories migrated and tests passing, we removed Prisma:

npm uninstall prisma @prisma/client
rm -rf prisma/

Updated our database service:

// Before
@Injectable()
export class DatabaseService {
  constructor(
    private prisma: PrismaService,
    private drizzle: DrizzleService
  ) {}
}
 
// After
@Injectable()
export class DatabaseService {
  constructor(private db: DrizzleService) {}
}

Key Differences: Prisma vs. Drizzle

Query Builder vs. Schema First

Prisma is schema-first. You define schema in Prisma language, generate TypeScript:

model User {
  id    String @id
  email String @unique
}

Drizzle is code-first. You define schema in TypeScript:

export const users = pgTable('users', {
  id: uuid('id').primaryKey(),
  email: varchar('email', { length: 255 }).unique(),
});

I prefer code-first. No code generation step, and TypeScript is the source of truth.

Type Inference

Prisma generates types from schema. Drizzle infers types from your queries.

// Drizzle - type is exactly what you select
const result = await db
  .select({
    id: users.id,
    email: users.email,
  })
  .from(users);
 
// Type: { id: string; email: string }[]
// If you don't select it, it's not in the type!

This caught bugs where we assumed fields were loaded but weren't.

Relations

Prisma handles relations automatically:

const user = await prisma.user.findUnique({
  where: { id: '123' },
  include: { posts: true },
});

Drizzle requires explicit joins:

const result = await db
  .select()
  .from(users)
  .leftJoin(posts, eq(posts.userId, users.id))
  .where(eq(users.id, '123'));

More verbose, but you see exactly what SQL runs.

Migrations

Prisma has a full migration system with rollback:

prisma migrate dev
prisma migrate deploy

Drizzle generates SQL, but you run it:

drizzle-kit generate:pg
# Creates SQL file in drizzle/migrations/
# You run it with your preferred tool

We used our existing migration runner (postgres-migrations).

Performance Improvements

After migration, we measured performance across 50 key queries:

  • Average query time: 40% faster
  • P95 latency: 60% faster
  • Complex reports: 70% faster
  • Memory usage: 30% lower

The biggest gains came from:

  1. Optimized joins: We controlled exactly how tables joined
  2. Partial selects: Only selecting needed columns
  3. Better indexes: Drizzle made it obvious what we needed to index

Example optimization:

// Prisma (slow)
const projects = await prisma.project.findMany({
  include: {
    client: true,
    tasks: {
      include: {
        assignee: true,
      },
    },
  },
});
 
// Drizzle (fast)
const projects = await db
  .select({
    projectId: projects.id,
    projectName: projects.name,
    clientName: clients.name,
    taskCount: sql<number>`count(${tasks.id})`,
  })
  .from(projects)
  .leftJoin(clients, eq(projects.clientId, clients.id))
  .leftJoin(tasks, eq(tasks.projectId, projects.id))
  .groupBy(projects.id, clients.name);

The Drizzle version does aggregation in the database, not in application memory.

Challenges and Solutions

Challenge 1: Complex Nested Queries

Prisma's nested includes were convenient. Drizzle requires manual mapping:

// Helper function for nested data
function groupTasksByProject(rows: any[]): Project[] {
  const projectMap = new Map<string, Project>();
 
  for (const row of rows) {
    let project = projectMap.get(row.projects.id);
 
    if (!project) {
      project = {
        ...row.projects,
        client: row.clients,
        tasks: [],
      };
      projectMap.set(row.projects.id, project);
    }
 
    if (row.tasks) {
      project.tasks.push(row.tasks);
    }
  }
 
  return Array.from(projectMap.values());
}

Challenge 2: Transaction Syntax

Prisma transactions:

await prisma.$transaction(async (tx) => {
  await tx.project.create({ data: { ... } });
  await tx.task.create({ data: { ... } });
});

Drizzle transactions:

await db.transaction(async (tx) => {
  await tx.insert(projects).values({ ... });
  await tx.insert(tasks).values({ ... });
});

Similar but subtly different. We created wrapper functions to minimize changes.

Challenge 3: Testing

We had extensive tests using Prisma. We needed to update all of them:

// Test helper to swap implementations
beforeEach(async () => {
  const module = await Test.createTestingModule({
    providers: [
      {
        provide: 'ProjectRepository',
        useClass: DrizzleProjectRepository, // Changed from PrismaProjectRepository
      },
    ],
  }).compile();
});

We ran both implementations in parallel for a week, comparing results to ensure correctness.

Would I Do It Again?

Absolutely. The migration took 10 weeks with a team of 3 developers, but the benefits were immediate:

  • Better performance meant happier users
  • More control meant we could optimize critical paths
  • Smaller bundle improved cold starts in serverless
  • Faster migrations improved developer productivity

Should You Migrate?

Consider migrating if:

  • Performance is critical and you need query control
  • You're comfortable writing SQL
  • You want stricter type safety
  • Your team understands databases well

Stay with Prisma if:

  • Performance is adequate
  • You prefer high-level abstractions
  • Your team is less experienced with SQL
  • You love Prisma Studio (Drizzle has no equivalent)

Migration Tips

  1. Start small: Migrate one module, verify it works, then expand
  2. Run in parallel: Keep both ORMs for a transition period
  3. Test extensively: Compare outputs from both implementations
  4. Use views: Bridge schema differences without downtime
  5. Monitor performance: Track query performance before and after
  6. Document differences: Help your team understand the changes

Conclusion

Migrating from Prisma to Drizzle was one of our best technical decisions. It gave us the control and performance we needed while maintaining type safety and developer experience.

The key is approaching it methodically—don't rush, test everything, and migrate incrementally. After 27 years of building applications, I've learned that good migrations are never big-bang rewrites; they're careful, measured transitions.

If you're hitting Prisma's limitations and your team has strong SQL skills, Drizzle is worth serious consideration. Just plan for a 2-3 month migration timeline for a large codebase.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.