Back to blog

ORM Guide: Prisma vs TypeORM vs Drizzle

ormtypescriptdatabasenodejsbackend
ORM Guide: Prisma vs TypeORM vs Drizzle

Introduction

Every backend TypeScript project faces the same question: how do you talk to your database? Raw SQL is powerful but error-prone. Query builders are flexible but verbose. ORMs promise productivity but sometimes hide too much.

The TypeScript ecosystem has three major ORMs, each with a radically different philosophy: Prisma (schema-first, auto-generated client), TypeORM (decorator-based, ActiveRecord/DataMapper), and Drizzle (SQL-like, zero abstraction). Choosing wrong can cost months of refactoring.

This guide gives you the complete picture so you can make the right choice for your project.

What You'll Learn

✅ What ORMs are and when you actually need one
✅ Prisma's architecture: schema-first design, Prisma Client, and migrations
✅ TypeORM's architecture: decorators, ActiveRecord vs DataMapper patterns
✅ Drizzle's architecture: SQL-like syntax with full type safety
✅ Side-by-side comparison of queries, relations, migrations, and performance
✅ Real-world scenarios: which ORM fits which project type
✅ Migration strategies between ORMs

Prerequisites


1. Understanding ORMs

What is an ORM?

An ORM (Object-Relational Mapping) maps database tables to programming language objects, so you write code instead of SQL.

The Spectrum of Database Access

ApproachType SafetyLearning CurveSQL ControlProductivity
Raw SQL (pg, mysql2)❌ NoneLowFullLow
Query Builder (Knex.js)⚠️ PartialMediumHighMedium
Drizzle ORM✅ FullLow-MediumHighHigh
TypeORM✅ FullMedium-HighMediumHigh
Prisma✅ FullLowLow-MediumVery High

When Do You Need an ORM?

Use an ORM when:

  • You want type-safe database queries
  • Your app has complex relations (1:N, M:N)
  • You need migration management
  • You value developer productivity over SQL control

Skip the ORM when:

  • Performance-critical queries need hand-tuned SQL
  • You're building a simple script with a few queries
  • Your team prefers writing SQL directly

2. Prisma: Schema-First ORM

Philosophy

Prisma takes a schema-first approach. You define your data model in a .prisma file, and Prisma generates a fully type-safe client. You never write SQL or decorators — the schema is the single source of truth.

Setup

npm install prisma @prisma/client
npx prisma init

This creates:

prisma/
├── schema.prisma    # Data model definition
└── migrations/      # Migration history

Schema Definition

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  role      Role      @default(USER)
  posts     Post[]
  profile   Profile?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
 
  @@index([email])
  @@map("users")
}
 
model Post {
  id          Int        @id @default(autoincrement())
  title       String
  content     String?
  published   Boolean    @default(false)
  author      User       @relation(fields: [authorId], references: [id])
  authorId    Int
  categories  Category[]
  createdAt   DateTime   @default(now())
 
  @@index([authorId])
  @@map("posts")
}
 
model Profile {
  id     Int    @id @default(autoincrement())
  bio    String
  user   User   @relation(fields: [userId], references: [id])
  userId Int    @unique
 
  @@map("profiles")
}
 
model Category {
  id    Int    @id @default(autoincrement())
  name  String @unique
  posts Post[]
 
  @@map("categories")
}
 
enum Role {
  USER
  ADMIN
  MODERATOR
}

CRUD Operations

import { PrismaClient } from '@prisma/client';
 
const prisma = new PrismaClient();
 
// CREATE
const user = await prisma.user.create({
  data: {
    email: 'john@example.com',
    name: 'John Doe',
    profile: {
      create: { bio: 'Full-stack developer' }  // Nested create
    }
  },
  include: { profile: true }  // Include relation in response
});
 
// READ with filtering
const publishedPosts = await prisma.post.findMany({
  where: {
    published: true,
    author: {
      role: 'ADMIN'  // Filter through relation
    }
  },
  include: {
    author: { select: { name: true, email: true } },
    categories: true
  },
  orderBy: { createdAt: 'desc' },
  take: 10,
  skip: 0
});
 
// UPDATE
const updated = await prisma.user.update({
  where: { email: 'john@example.com' },
  data: { role: 'ADMIN' }
});
 
// DELETE with cascade
const deleted = await prisma.user.delete({
  where: { id: 1 }
});
 
// UPSERT
const upserted = await prisma.user.upsert({
  where: { email: 'john@example.com' },
  update: { name: 'John Updated' },
  create: { email: 'john@example.com', name: 'John Doe' }
});

Relations and Nested Queries

// Create post with categories (M:N)
const post = await prisma.post.create({
  data: {
    title: 'TypeScript ORMs',
    content: 'Comparing Prisma, TypeORM, and Drizzle...',
    author: { connect: { id: 1 } },
    categories: {
      connectOrCreate: [
        {
          where: { name: 'TypeScript' },
          create: { name: 'TypeScript' }
        },
        {
          where: { name: 'Database' },
          create: { name: 'Database' }
        }
      ]
    }
  },
  include: { categories: true, author: true }
});
 
// Aggregate queries
const stats = await prisma.post.aggregate({
  _count: { id: true },
  _avg: { authorId: true },
  where: { published: true }
});
 
// Group by
const postsByAuthor = await prisma.post.groupBy({
  by: ['authorId'],
  _count: { id: true },
  having: {
    id: { _count: { gt: 5 } }
  }
});

Migrations

# Create migration from schema changes
npx prisma migrate dev --name add_user_role
 
# Apply migrations in production
npx prisma migrate deploy
 
# Reset database (destructive)
npx prisma migrate reset
 
# Generate client without migration
npx prisma generate

Prisma Strengths and Weaknesses

StrengthsWeaknesses
Best-in-class type safetyCannot write raw SQL in type-safe way
Intuitive API — easy to learnPrisma Client adds bundle size
Prisma Studio (visual DB browser)Schema language is proprietary
Excellent migration managementComplex raw queries need $queryRaw
Auto-generated, always-in-sync typesJoins are done at application level
Great documentationCannot do certain SQL patterns natively

3. TypeORM: Decorator-Based ORM

Philosophy

TypeORM follows the traditional ORM pattern inspired by Java's Hibernate and C#'s Entity Framework. You define entities using TypeScript decorators and choose between ActiveRecord or DataMapper patterns.

Setup

npm install typeorm reflect-metadata @types/node pg
// tsconfig.json — required settings
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strictPropertyInitialization": false
  }
}

Data Source Configuration

// data-source.ts
import { DataSource } from 'typeorm';
import { User } from './entities/User';
import { Post } from './entities/Post';
import { Profile } from './entities/Profile';
import { Category } from './entities/Category';
 
export const AppDataSource = new DataSource({
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'postgres',
  password: 'password',
  database: 'myapp',
  synchronize: false,  // Never true in production!
  logging: true,
  entities: [User, Post, Profile, Category],
  migrations: ['./migrations/*.ts']
});

Entity Definitions

// entities/User.ts
import {
  Entity, PrimaryGeneratedColumn, Column, OneToMany,
  OneToOne, CreateDateColumn, UpdateDateColumn, Index
} from 'typeorm';
import { Post } from './Post';
import { Profile } from './Profile';
 
export enum Role {
  USER = 'USER',
  ADMIN = 'ADMIN',
  MODERATOR = 'MODERATOR'
}
 
@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column({ unique: true })
  @Index()
  email: string;
 
  @Column({ nullable: true })
  name: string;
 
  @Column({ type: 'enum', enum: Role, default: Role.USER })
  role: Role;
 
  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
 
  @OneToOne(() => Profile, (profile) => profile.user)
  profile: Profile;
 
  @CreateDateColumn()
  createdAt: Date;
 
  @UpdateDateColumn()
  updatedAt: Date;
}
// entities/Post.ts
import {
  Entity, PrimaryGeneratedColumn, Column, ManyToOne,
  ManyToMany, JoinTable, CreateDateColumn, Index
} from 'typeorm';
import { User } from './User';
import { Category } from './Category';
 
@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column()
  title: string;
 
  @Column({ type: 'text', nullable: true })
  content: string;
 
  @Column({ default: false })
  published: boolean;
 
  @ManyToOne(() => User, (user) => user.posts)
  @Index()
  author: User;
 
  @Column()
  authorId: number;
 
  @ManyToMany(() => Category, (category) => category.posts)
  @JoinTable()
  categories: Category[];
 
  @CreateDateColumn()
  createdAt: Date;
}
// entities/Profile.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  OneToOne, JoinColumn
} from 'typeorm';
import { User } from './User';
 
@Entity('profiles')
export class Profile {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column()
  bio: string;
 
  @OneToOne(() => User, (user) => user.profile)
  @JoinColumn()
  user: User;
 
  @Column()
  userId: number;
}

CRUD Operations (DataMapper Pattern)

const userRepo = AppDataSource.getRepository(User);
const postRepo = AppDataSource.getRepository(Post);
 
// CREATE
const user = userRepo.create({
  email: 'john@example.com',
  name: 'John Doe'
});
await userRepo.save(user);
 
// CREATE with relation
const profile = new Profile();
profile.bio = 'Full-stack developer';
profile.user = user;
await AppDataSource.getRepository(Profile).save(profile);
 
// READ with filtering
const publishedPosts = await postRepo.find({
  where: {
    published: true,
    author: { role: Role.ADMIN }
  },
  relations: { author: true, categories: true },
  order: { createdAt: 'DESC' },
  take: 10,
  skip: 0
});
 
// UPDATE
await userRepo.update(
  { email: 'john@example.com' },
  { role: Role.ADMIN }
);
 
// DELETE
await userRepo.delete({ id: 1 });
 
// UPSERT (TypeORM 0.3+)
await userRepo.upsert(
  { email: 'john@example.com', name: 'John Updated' },
  ['email']
);

QueryBuilder for Complex Queries

// TypeORM's QueryBuilder for advanced queries
const posts = await postRepo
  .createQueryBuilder('post')
  .innerJoinAndSelect('post.author', 'author')
  .leftJoinAndSelect('post.categories', 'category')
  .where('post.published = :published', { published: true })
  .andWhere('author.role IN (:...roles)', { roles: ['ADMIN', 'MODERATOR'] })
  .orderBy('post.createdAt', 'DESC')
  .take(10)
  .skip(0)
  .getMany();
 
// Aggregate with QueryBuilder
const stats = await postRepo
  .createQueryBuilder('post')
  .select('post.authorId', 'authorId')
  .addSelect('COUNT(post.id)', 'postCount')
  .where('post.published = true')
  .groupBy('post.authorId')
  .having('COUNT(post.id) > :minPosts', { minPosts: 5 })
  .getRawMany();
 
// Subqueries
const usersWithPosts = await userRepo
  .createQueryBuilder('user')
  .where((qb) => {
    const subQuery = qb
      .subQuery()
      .select('post.authorId')
      .from(Post, 'post')
      .where('post.published = true')
      .getQuery();
    return `user.id IN ${subQuery}`;
  })
  .getMany();

Migrations

# Generate migration from entity changes
npx typeorm migration:generate ./migrations/AddUserRole -d ./data-source.ts
 
# Create empty migration
npx typeorm migration:create ./migrations/SeedData
 
# Run migrations
npx typeorm migration:run -d ./data-source.ts
 
# Revert last migration
npx typeorm migration:revert -d ./data-source.ts

TypeORM Strengths and Weaknesses

StrengthsWeaknesses
Familiar to Java/C# developersDecorator syntax is verbose
ActiveRecord + DataMapper patternsType safety gaps (relations can be undefined)
Powerful QueryBuildersynchronize: true can destroy production data
Supports many databasesSlow release cycle, many open issues
Mature ecosystemEntity metadata requires reflect-metadata
Transaction support is excellentComplex setup with tsconfig requirements

4. Drizzle: SQL-Like ORM

Philosophy

Drizzle takes the "if you know SQL, you know Drizzle" approach. It's a thin, type-safe layer over SQL that doesn't hide the underlying queries. You define schemas in TypeScript (no decorators, no proprietary languages), and queries look like SQL.

Setup

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

Schema Definition

// db/schema.ts
import {
  pgTable, serial, varchar, text, boolean, integer,
  timestamp, pgEnum, uniqueIndex, index
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
// Enum
export const roleEnum = pgEnum('role', ['USER', 'ADMIN', 'MODERATOR']);
 
// Tables
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 255 }),
  role: roleEnum('role').default('USER').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
  uniqueIndex('email_idx').on(table.email),
]);
 
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content'),
  published: boolean('published').default(false).notNull(),
  authorId: integer('author_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
  index('author_idx').on(table.authorId),
]);
 
export const profiles = pgTable('profiles', {
  id: serial('id').primaryKey(),
  bio: text('bio').notNull(),
  userId: integer('user_id').notNull().unique().references(() => users.id),
});
 
export const categories = pgTable('categories', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull().unique(),
});
 
// Many-to-many junction table
export const postsToCategories = pgTable('posts_categories', {
  postId: integer('post_id').notNull().references(() => posts.id),
  categoryId: integer('category_id').notNull().references(() => categories.id),
}, (table) => [
  index('post_category_idx').on(table.postId, table.categoryId),
]);
 
// Relations (for query API)
export const usersRelations = relations(users, ({ many, one }) => ({
  posts: many(posts),
  profile: one(profiles, {
    fields: [users.id],
    references: [profiles.userId],
  }),
}));
 
export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  categories: many(postsToCategories),
}));
 
export const postsToCategoriesRelations = relations(postsToCategories, ({ one }) => ({
  post: one(posts, {
    fields: [postsToCategories.postId],
    references: [posts.id],
  }),
  category: one(categories, {
    fields: [postsToCategories.categoryId],
    references: [categories.id],
  }),
}));

Database Connection

// db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
 
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

CRUD Operations

import { eq, and, desc, sql, inArray, gt, count } from 'drizzle-orm';
import { db } from './db';
import { users, posts, profiles, categories, postsToCategories } from './db/schema';
 
// CREATE
const [user] = await db.insert(users).values({
  email: 'john@example.com',
  name: 'John Doe',
}).returning();
 
// CREATE with relation (separate insert)
await db.insert(profiles).values({
  bio: 'Full-stack developer',
  userId: user.id,
});
 
// READ with filtering (SQL-like API)
const publishedPosts = await db
  .select()
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(
    and(
      eq(posts.published, true),
      inArray(users.role, ['ADMIN', 'MODERATOR'])
    )
  )
  .orderBy(desc(posts.createdAt))
  .limit(10)
  .offset(0);
 
// READ with relations (Query API)
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: {
      where: eq(posts.published, true),
      limit: 5,
    },
    profile: true,
  },
});
 
// UPDATE
await db.update(users)
  .set({ role: 'ADMIN' })
  .where(eq(users.email, 'john@example.com'));
 
// DELETE
await db.delete(users).where(eq(users.id, 1));
 
// UPSERT (ON CONFLICT)
await db.insert(users)
  .values({ email: 'john@example.com', name: 'John Updated' })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: 'John Updated' },
  });

Advanced Queries

// Aggregate queries — looks like SQL
const stats = await db
  .select({
    authorId: posts.authorId,
    postCount: count(posts.id),
  })
  .from(posts)
  .where(eq(posts.published, true))
  .groupBy(posts.authorId)
  .having(gt(count(posts.id), 5));
 
// Subqueries
const activeAuthors = db
  .select({ authorId: posts.authorId })
  .from(posts)
  .where(eq(posts.published, true))
  .as('active_authors');
 
const usersWithPublished = await db
  .select()
  .from(users)
  .innerJoin(activeAuthors, eq(users.id, activeAuthors.authorId));
 
// Raw SQL when needed (still type-safe)
const result = await db.execute(
  sql`SELECT * FROM users WHERE email ILIKE ${'%@example.com'}`
);
 
// Transactions
await db.transaction(async (tx) => {
  const [newUser] = await tx.insert(users).values({
    email: 'jane@example.com',
    name: 'Jane',
  }).returning();
 
  await tx.insert(posts).values({
    title: 'First Post',
    authorId: newUser.id,
  });
});

Migrations

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
export default defineConfig({
  schema: './db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});
# Generate migration from schema changes
npx drizzle-kit generate
 
# Apply migrations
npx drizzle-kit migrate
 
# Push schema directly (dev only)
npx drizzle-kit push
 
# Open Drizzle Studio (visual DB browser)
npx drizzle-kit studio

Drizzle Strengths and Weaknesses

StrengthsWeaknesses
SQL-like syntax — no abstraction leakRelations API is separate from SQL API
Smallest bundle size (~50KB)Younger ecosystem, fewer tutorials
No code generation stepM:N relations need manual junction tables
Works with any runtime (Node, Bun, Deno)No ActiveRecord pattern
Schema is plain TypeScriptRelational queries need explicit relation definitions
Drizzle Studio for visual browsingLess "magic" — more boilerplate for relations

5. Side-by-Side Comparison

Schema Definition

FeaturePrismaTypeORMDrizzle
Language.prisma DSLTypeScript decoratorsTypeScript functions
Locationschema.prismaEntity classesSchema files
RelationsImplicit via fieldsDecorators (@OneToMany)Explicit relations()
Enumsenum in schemaTypeScript enum + decoratorpgEnum()
Indexes@@index@Index()Table callback
ValidationSchema-levelDecorator-basedSchema-level

Query API Comparison

Simple find by ID:

// Prisma
const user = await prisma.user.findUnique({
  where: { id: 1 }
});
 
// TypeORM
const user = await userRepo.findOneBy({ id: 1 });
 
// Drizzle
const [user] = await db.select().from(users).where(eq(users.id, 1));
// or with query API:
const user = await db.query.users.findFirst({
  where: eq(users.id, 1)
});

Find with relations:

// Prisma — nested include
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: { where: { published: true } },
    profile: true
  }
});
 
// TypeORM — relations option
const user = await userRepo.findOne({
  where: { id: 1 },
  relations: { posts: true, profile: true }
});
// or QueryBuilder for filtering relations:
const user = await userRepo
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.posts', 'post', 'post.published = true')
  .leftJoinAndSelect('user.profile', 'profile')
  .where('user.id = :id', { id: 1 })
  .getOne();
 
// Drizzle — query API
const user = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: {
    posts: { where: eq(posts.published, true) },
    profile: true
  }
});

Complex aggregation:

// Prisma
const result = await prisma.post.groupBy({
  by: ['authorId'],
  _count: { id: true },
  _avg: { authorId: true },
  where: { published: true },
  having: { id: { _count: { gt: 5 } } }
});
 
// TypeORM (QueryBuilder)
const result = await postRepo
  .createQueryBuilder('post')
  .select('post.authorId', 'authorId')
  .addSelect('COUNT(post.id)', 'postCount')
  .where('post.published = true')
  .groupBy('post.authorId')
  .having('COUNT(post.id) > :min', { min: 5 })
  .getRawMany();
 
// Drizzle
const result = await db
  .select({
    authorId: posts.authorId,
    postCount: count(posts.id),
  })
  .from(posts)
  .where(eq(posts.published, true))
  .groupBy(posts.authorId)
  .having(gt(count(posts.id), 5));

Feature Matrix

FeaturePrismaTypeORMDrizzle
Type Safety⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Learning CurveEasyMedium-HardEasy-Medium
Bundle Size~10MB (engine)~2MB~50KB
Raw SQL Support$queryRawquery()sql template
Migration ToolBuilt-inBuilt-inDrizzle Kit
Visual StudioPrisma StudioDrizzle Studio
DatabasesPG, MySQL, SQLite, MongoDB, CockroachDB, SQL ServerPG, MySQL, SQLite, SQL Server, Oracle, MongoDBPG, MySQL, SQLite, Turso
Code GenerationRequired (prisma generate)Not neededNot needed
Nested Creates✅ Built-in❌ Manual❌ Manual
ActiveRecord
DataMapper✅ (default)✅ (default)
Transactions$transaction()transaction()transaction()
Edge/Serverless✅ (Prisma Accelerate)⚠️ Limited✅ Native
Runtime SupportNode.js (Bun partial)Node.jsNode, Bun, Deno

Migration Comparison

AspectPrismaTypeORMDrizzle
GenerationFrom schema diffFrom entity diffFrom schema diff
FormatSQL filesTypeScript classesSQL files
Applyprisma migrate deploymigration:rundrizzle-kit migrate
RevertManualmigration:revertManual
Dev modeprisma migrate devsynchronize: true (dangerous!)drizzle-kit push
Seedprisma db seedCustom scriptCustom script
Safety✅ Warns about data loss⚠️ synchronize can drop tables✅ Push warns

6. Performance Characteristics

Query Generation

Prisma:

TypeORM:

Drizzle:

MetricPrismaTypeORMDrizzle
Cold StartSlow (engine init)MediumFast
Query OverheadHigher (Rust engine IPC)Medium (JS query building)Minimal (thin layer)
Connection PoolingBuilt-inVia driverVia driver
ServerlessNeeds Prisma AccelerateCold start issuesBest (smallest bundle)
N+1 Preventioninclude resolvesManual relationswith in query API
Batch OperationscreateMany, updateManysave(array), QueryBuilderinsert().values([...])

Serverless & Edge Considerations

For serverless/edge: Drizzle wins due to minimal bundle size and no engine dependency. Prisma requires Prisma Accelerate (a proxy service) for edge deployments. TypeORM has connection management issues in serverless.


7. Real-World Scenarios

Scenario 1: SaaS Application (Multi-tenant)

Best choice: Prisma

// Prisma excels at complex nested operations
const tenant = await prisma.tenant.create({
  data: {
    name: 'Acme Corp',
    subscription: { create: { plan: 'PRO', expiresAt: nextYear } },
    users: {
      create: [
        {
          email: 'admin@acme.com',
          role: 'ADMIN',
          profile: { create: { bio: 'Tenant admin' } }
        }
      ]
    }
  },
  include: {
    subscription: true,
    users: { include: { profile: true } }
  }
});

Why Prisma: Nested creates, excellent type safety, Prisma Studio for debugging, great developer experience for rapid feature development.

Scenario 2: Serverless API (Vercel, Cloudflare Workers)

Best choice: Drizzle

// Drizzle: minimal cold start, works on edge
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
 
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
 
export default async function handler(req: Request) {
  const users = await db.select().from(usersTable).limit(10);
  return Response.json(users);
}

Why Drizzle: ~50KB bundle, no binary engine, native edge support, works with Neon, PlanetScale, Turso serverless databases.

Scenario 3: Enterprise Application (Complex Domain)

Best choice: TypeORM

// TypeORM: familiar patterns for enterprise teams
@Entity()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
export abstract class Payment {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column('decimal')
  amount: number;
}
 
@ChildEntity()
export class CreditCardPayment extends Payment {
  @Column()
  cardLastFour: string;
}
 
@ChildEntity()
export class BankTransferPayment extends Payment {
  @Column()
  bankName: string;
}

Why TypeORM: Inheritance mapping, ActiveRecord for rapid prototyping, familiar patterns for Java/C# teams transitioning to TypeScript, mature ecosystem.

Scenario 4: Microservice with High Performance Needs

Best choice: Drizzle

// Drizzle: hand-tuned queries with type safety
const result = await db
  .select({
    userId: users.id,
    userName: users.name,
    totalOrders: count(orders.id),
    totalRevenue: sql<number>`SUM(${orders.amount})`,
  })
  .from(users)
  .leftJoin(orders, eq(users.id, orders.userId))
  .where(
    and(
      gt(orders.createdAt, sql`NOW() - INTERVAL '30 days'`),
      eq(orders.status, 'completed')
    )
  )
  .groupBy(users.id, users.name)
  .orderBy(desc(sql`SUM(${orders.amount})`))
  .limit(100);

Why Drizzle: Near-zero overhead, SQL-level control, smallest runtime footprint, great for performance-critical services.

Decision Flowchart


8. Migrating Between ORMs

From TypeORM to Prisma

# 1. Introspect existing database
npx prisma db pull
 
# 2. This generates schema.prisma from your existing tables
# 3. Generate Prisma Client
npx prisma generate
 
# 4. Replace TypeORM queries gradually

Before (TypeORM):

const users = await userRepo.find({
  where: { role: Role.ADMIN },
  relations: { posts: true },
  order: { createdAt: 'DESC' }
});

After (Prisma):

const users = await prisma.user.findMany({
  where: { role: 'ADMIN' },
  include: { posts: true },
  orderBy: { createdAt: 'desc' }
});

From Prisma to Drizzle

# 1. Use drizzle-kit to introspect existing database
npx drizzle-kit introspect
 
# 2. This generates schema.ts from your existing tables
# 3. Replace Prisma queries gradually

Before (Prisma):

const user = await prisma.user.findUnique({
  where: { email: 'john@example.com' },
  include: { posts: true }
});

After (Drizzle):

const user = await db.query.users.findFirst({
  where: eq(users.email, 'john@example.com'),
  with: { posts: true }
});

Migration Tips

  1. Don't rewrite all at once — migrate query by query
  2. Keep both ORMs installed during transition
  3. Use database introspection to generate the new ORM's schema
  4. Write integration tests before migrating to catch regressions
  5. Start with read queries (lower risk), then migrate writes

9. Common Pitfalls

Pitfall 1: The N+1 Query Problem

// ❌ Bad — N+1 queries (all ORMs)
const users = await getUsers();
for (const user of users) {
  const posts = await getPostsByUserId(user.id);  // N queries!
}
 
// ✅ Good — eager load relations
 
// Prisma
const users = await prisma.user.findMany({
  include: { posts: true }  // Single query with JOIN
});
 
// TypeORM
const users = await userRepo.find({
  relations: { posts: true }
});
 
// Drizzle
const users = await db.query.users.findMany({
  with: { posts: true }
});

Pitfall 2: TypeORM's synchronize in Production

// ❌ NEVER do this in production
const dataSource = new DataSource({
  synchronize: true  // Can drop columns, tables, data!
});
 
// ✅ Always use migrations in production
const dataSource = new DataSource({
  synchronize: false,
  migrations: ['./migrations/*.ts']
});

Pitfall 3: Prisma's Client Not Regenerated

# After changing schema.prisma, you MUST regenerate
npx prisma generate
 
# Common symptom: TypeScript types don't match your schema
# Fix: run generate after every schema change

Pitfall 4: Drizzle's Two Query APIs

// Drizzle has TWO APIs — they serve different purposes
 
// 1. SQL-like API (select/insert/update/delete)
//    Best for: complex queries, aggregations, JOINs
const result = await db.select().from(users).where(eq(users.id, 1));
 
// 2. Relational Query API (query.table.findMany)
//    Best for: loading entities with relations
const result = await db.query.users.findFirst({
  where: eq(users.id, 1),
  with: { posts: true }
});
 
// Don't mix them up — use the right API for the job

Summary and Key Takeaways

Prisma — best developer experience, schema-first, auto-generated type-safe client, ideal for rapid development and SaaS apps
TypeORM — decorator-based, familiar to Java/C# developers, ActiveRecord + DataMapper patterns, best for enterprise teams
Drizzle — SQL-like syntax, smallest bundle (~50KB), best for serverless/edge and performance-critical apps
All three provide full TypeScript type safety — the choice depends on your project's needs
For most new projects, start with Prisma (best DX) or Drizzle (best performance)
TypeORM is best when your team comes from Java/C# and prefers decorator patterns
For serverless/edge, Drizzle is the clear winner (smallest bundle, no engine dependency)
Migration between ORMs is possible using database introspection tools
Avoid common pitfalls: N+1 queries, TypeORM's synchronize, Prisma's missing generate

What's Next?

Now that you understand TypeScript ORMs, explore these topics:

Build with ORMs:

Database Fundamentals:

API Development:

Related Comparisons:


📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.