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
- TypeScript fundamentals (TypeScript Full-Stack Roadmap)
- Basic SQL knowledge (SQL Fundamentals)
- Relational database concepts (Relational Database Fundamentals)
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
| Approach | Type Safety | Learning Curve | SQL Control | Productivity |
|---|---|---|---|---|
Raw SQL (pg, mysql2) | ❌ None | Low | Full | Low |
| Query Builder (Knex.js) | ⚠️ Partial | Medium | High | Medium |
| Drizzle ORM | ✅ Full | Low-Medium | High | High |
| TypeORM | ✅ Full | Medium-High | Medium | High |
| Prisma | ✅ Full | Low | Low-Medium | Very 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 initThis creates:
prisma/
├── schema.prisma # Data model definition
└── migrations/ # Migration historySchema 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 generatePrisma Strengths and Weaknesses
| Strengths | Weaknesses |
|---|---|
| Best-in-class type safety | Cannot write raw SQL in type-safe way |
| Intuitive API — easy to learn | Prisma Client adds bundle size |
| Prisma Studio (visual DB browser) | Schema language is proprietary |
| Excellent migration management | Complex raw queries need $queryRaw |
| Auto-generated, always-in-sync types | Joins are done at application level |
| Great documentation | Cannot 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.tsTypeORM Strengths and Weaknesses
| Strengths | Weaknesses |
|---|---|
| Familiar to Java/C# developers | Decorator syntax is verbose |
| ActiveRecord + DataMapper patterns | Type safety gaps (relations can be undefined) |
| Powerful QueryBuilder | synchronize: true can destroy production data |
| Supports many databases | Slow release cycle, many open issues |
| Mature ecosystem | Entity metadata requires reflect-metadata |
| Transaction support is excellent | Complex 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-kitSchema 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 studioDrizzle Strengths and Weaknesses
| Strengths | Weaknesses |
|---|---|
| SQL-like syntax — no abstraction leak | Relations API is separate from SQL API |
| Smallest bundle size (~50KB) | Younger ecosystem, fewer tutorials |
| No code generation step | M:N relations need manual junction tables |
| Works with any runtime (Node, Bun, Deno) | No ActiveRecord pattern |
| Schema is plain TypeScript | Relational queries need explicit relation definitions |
| Drizzle Studio for visual browsing | Less "magic" — more boilerplate for relations |
5. Side-by-Side Comparison
Schema Definition
| Feature | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Language | .prisma DSL | TypeScript decorators | TypeScript functions |
| Location | schema.prisma | Entity classes | Schema files |
| Relations | Implicit via fields | Decorators (@OneToMany) | Explicit relations() |
| Enums | enum in schema | TypeScript enum + decorator | pgEnum() |
| Indexes | @@index | @Index() | Table callback |
| Validation | Schema-level | Decorator-based | Schema-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
| Feature | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Type Safety | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Learning Curve | Easy | Medium-Hard | Easy-Medium |
| Bundle Size | ~10MB (engine) | ~2MB | ~50KB |
| Raw SQL Support | $queryRaw | query() | sql template |
| Migration Tool | Built-in | Built-in | Drizzle Kit |
| Visual Studio | Prisma Studio | — | Drizzle Studio |
| Databases | PG, MySQL, SQLite, MongoDB, CockroachDB, SQL Server | PG, MySQL, SQLite, SQL Server, Oracle, MongoDB | PG, MySQL, SQLite, Turso |
| Code Generation | Required (prisma generate) | Not needed | Not needed |
| Nested Creates | ✅ Built-in | ❌ Manual | ❌ Manual |
| ActiveRecord | ❌ | ✅ | ❌ |
| DataMapper | ✅ (default) | ✅ | ✅ (default) |
| Transactions | $transaction() | transaction() | transaction() |
| Edge/Serverless | ✅ (Prisma Accelerate) | ⚠️ Limited | ✅ Native |
| Runtime Support | Node.js (Bun partial) | Node.js | Node, Bun, Deno |
Migration Comparison
| Aspect | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Generation | From schema diff | From entity diff | From schema diff |
| Format | SQL files | TypeScript classes | SQL files |
| Apply | prisma migrate deploy | migration:run | drizzle-kit migrate |
| Revert | Manual | migration:revert | Manual |
| Dev mode | prisma migrate dev | synchronize: true (dangerous!) | drizzle-kit push |
| Seed | prisma db seed | Custom script | Custom script |
| Safety | ✅ Warns about data loss | ⚠️ synchronize can drop tables | ✅ Push warns |
6. Performance Characteristics
Query Generation
Prisma:
TypeORM:
Drizzle:
| Metric | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Cold Start | Slow (engine init) | Medium | Fast |
| Query Overhead | Higher (Rust engine IPC) | Medium (JS query building) | Minimal (thin layer) |
| Connection Pooling | Built-in | Via driver | Via driver |
| Serverless | Needs Prisma Accelerate | Cold start issues | Best (smallest bundle) |
| N+1 Prevention | include resolves | Manual relations | with in query API |
| Batch Operations | createMany, updateMany | save(array), QueryBuilder | insert().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 graduallyBefore (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 graduallyBefore (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
- Don't rewrite all at once — migrate query by query
- Keep both ORMs installed during transition
- Use database introspection to generate the new ORM's schema
- Write integration tests before migrating to catch regressions
- 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 changePitfall 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 jobSummary 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:
- TypeScript Backend Development - Build APIs with TypeScript and Prisma
- Database Schema Design Guide - Design schemas before choosing an ORM
Database Fundamentals:
- Relational Database Fundamentals - Understand the foundations
- SQL Fundamentals - Master SQL before abstracting it
API Development:
- What is REST API? - Complete guide to REST principles
- What is GraphQL? - Alternative API paradigm (works great with all three ORMs)
Related Comparisons:
- Bun vs Node.js vs Deno - Choose your runtime
- TypeScript Full-Stack Roadmap - Complete learning path
📬 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.