ORMs 101: The Ultimate Guide — Prisma, Drizzle, Sequelize, TypeORM & When to Use Raw SQL
Every backend developer eventually has to answer the question: "How should my application talk to the database?" You can write raw SQL, use a query builder, or reach for an ORM. Each choice has real consequences for your codebase, performance, and sanity.
This guide covers every major ORM in the Node.js/TypeScript ecosystem, explains the trade-offs honestly, and gives you a framework for choosing the right tool. No hype, no tribalism — just practical knowledge.
What Is an ORM? Why Do They Exist?
ORM stands for Object-Relational Mapping. It's a technique (and the libraries that implement it) for mapping between your application's objects/classes and your database's tables/rows.
The fundamental problem is called the Object-Relational Impedance Mismatch. Your code thinks in objects, methods, and hierarchies. Your database thinks in tables, rows, and joins. These two worlds don't map cleanly onto each other.
Your Code Your Database
========== =============
class User { +-----------+
id: number | users |
name: string <---??---> +-----------+
posts: Post[] | id INT |
profile: Profile | name TEXT |
} +-----------+
class Post { +-----------+
id: number | posts |
title: string <---??---> +-----------+
author: User | id INT |
} | title TEXT|
| user_id |
+-----------+
The "??" is the impedance mismatch.
An ORM bridges this gap.
What an ORM Does
- Schema mapping — defines how your objects correspond to tables
- Query generation — translates method calls into SQL
- Result hydration — converts database rows back into objects with relationships
- Migration management — tracks and applies schema changes over time
- Connection management — handles connection pooling and lifecycle
The Core Trade-off
More Abstraction
(easier to write, harder to optimize)
^
|
Prisma --------+
TypeORM -------+
Sequelize -----+
|
Drizzle -------+
Knex.js -------+
|
Raw SQL -------+
|
v
Less Abstraction
(harder to write, easier to optimize)
How ORMs Work Under the Hood
Before comparing specific tools, let's understand the machinery.
Query Building
When you write User.findMany({ where: { age: { gt: 18 } } }), the ORM:
- Parses your query object into an AST (Abstract Syntax Tree)
- Translates the AST into a SQL string:
SELECT * FROM users WHERE age > 18 - Parameterizes values to prevent SQL injection:
SELECT * FROM users WHERE age > $1with params[18] - Sends the query to the database driver
Connection Pooling
Opening a database connection is expensive (TCP handshake, authentication, TLS). ORMs maintain a pool of reusable connections.
Application Connection Pool Database
=========== =============== ========
Request 1 -----> [Get conn] ---> | Conn 1 | ---- query ----> PostgreSQL
Request 2 -----> [Get conn] ---> | Conn 2 | ---- query ---->
Request 3 -----> [Wait...] ----> | Conn 1 | ---- query ----> (Conn 1
(returned) returned
after Req 1
finished)
Typical pool size: 5-20 connections, depending on your database and workload.
The Two Patterns: Active Record vs Data Mapper
Active Record — the model object contains both data AND database operations:
// Active Record (TypeORM, Sequelize)
const user = new User();
user.name = "Jane";
user.email = "jane@example.com";
await user.save(); // The object saves itself
const users = await User.find({ where: { active: true } });
Data Mapper — a separate repository handles database operations:
// Data Mapper (TypeORM, Prisma-ish)
const user = new User();
user.name = "Jane";
user.email = "jane@example.com";
await userRepository.save(user); // A separate object saves it
const users = await userRepository.find({ where: { active: true } });
Data Mapper is generally considered better for large applications because it separates concerns. Active Record is simpler for small projects.
Prisma — The Modern Default
Prisma has become the de facto ORM for new TypeScript projects. It takes a different approach from traditional ORMs — it uses a schema file as the source of truth and generates a type-safe client.
The Schema File
// 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") // Table name in database
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
tags Tag[]
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 Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
@@map("tags")
}
enum Role {
USER
ADMIN
MODERATOR
}
Migrations
# Create a migration from schema changes
npx prisma migrate dev --name add-user-role
# Apply migrations in production
npx prisma migrate deploy
# Reset database (careful!)
npx prisma migrate reset
# View migration status
npx prisma migrate status
Migration Flow
===============
schema.prisma (edit) --> prisma migrate dev --> SQL migration file
|
v
prisma/migrations/
20260301_init/
migration.sql
20260305_add_role/
migration.sql
Prisma Client — Queries
After running npx prisma generate, you get a fully typed client.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Basic CRUD
const user = await prisma.user.create({
data: {
email: 'jane@example.com',
name: 'Jane Doe',
profile: {
create: { bio: 'Full-stack developer' }, // Nested create
},
},
include: {
profile: true, // Include relation in response
},
});
// Find with filters
const activeUsers = await prisma.user.findMany({
where: {
role: 'ADMIN',
posts: {
some: { published: true }, // Filter by relation
},
},
orderBy: { createdAt: 'desc' },
take: 10,
skip: 0,
});
// Update
const updated = await prisma.user.update({
where: { id: 1 },
data: {
name: 'Jane Smith',
posts: {
create: { title: 'New Post' }, // Create related record
},
},
});
// Delete with cascade
await prisma.user.delete({
where: { id: 1 },
// Relations with onDelete: Cascade are handled automatically
});
// Transactions
const [newUser, newPost] = await prisma.$transaction([
prisma.user.create({ data: { email: 'bob@example.com', name: 'Bob' } }),
prisma.post.create({ data: { title: 'Hello', authorId: 1 } }),
]);
// Interactive transactions
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: 1 } });
if (user.balance < amount) {
throw new Error('Insufficient balance');
}
await tx.user.update({
where: { id: 1 },
data: { balance: { decrement: amount } },
});
await tx.transfer.create({
data: { fromUserId: 1, amount },
});
});
Raw Queries When You Need Them
// Tagged template for safe parameterized queries
const users = await prisma.$queryRaw`
SELECT u.*, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.role = ${role}
GROUP BY u.id
HAVING COUNT(p.id) > ${minPosts}
ORDER BY post_count DESC
`;
// Execute raw (for INSERT/UPDATE/DELETE)
await prisma.$executeRaw`
UPDATE users SET last_login = NOW() WHERE id = ${userId}
`;
Prisma Studio
npx prisma studio
# Opens a web UI at localhost:5555 for browsing/editing data
It's like phpMyAdmin but built specifically for your Prisma schema. Great for debugging and quick data edits during development.
Prisma's Strengths
- Best-in-class TypeScript integration (autocomplete for everything)
- Schema file is readable and serves as documentation
- Migrations are automatic and diffable
- Prisma Studio for data exploration
- Excellent docs and community
- Works with PostgreSQL, MySQL, SQLite, MongoDB, SQL Server, CockroachDB
Prisma's Weaknesses
- Generated client adds to
node_modulessize - Some complex queries are awkward or impossible without raw SQL
- The Prisma engine is a Rust binary — adds to deployment size
- Migrations can be tricky to customize
- Performance overhead for simple queries (the Rust engine adds a hop)
Drizzle ORM — The "Thin ORM"
Drizzle's philosophy: stay as close to SQL as possible while still giving you TypeScript safety. It's gained massive popularity for being lightweight, fast, and SQL-like.
Schema Definition
// db/schema.ts
import {
pgTable, serial, text, varchar,
boolean, timestamp, integer, pgEnum,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const roleEnum = pgEnum('role', ['user', 'admin', 'moderator']);
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).unique().notNull(),
name: text('name'),
role: roleEnum('role').default('user').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
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').references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Define relations separately
export const usersRelations = relations(users, ({ many, one }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Queries — SQL-Like Syntax
import { drizzle } from 'drizzle-orm/node-postgres';
import { eq, gt, and, desc, count, sql } from 'drizzle-orm';
import * as schema from './schema';
const db = drizzle(pool, { schema });
// Select
const allUsers = await db.select().from(users);
// With conditions — reads like SQL
const admins = await db
.select()
.from(users)
.where(eq(users.role, 'admin'))
.orderBy(desc(users.createdAt))
.limit(10);
// Join
const usersWithPosts = await db
.select({
userName: users.name,
postTitle: posts.title,
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.where(eq(posts.published, true));
// Aggregation
const postCounts = await db
.select({
authorId: posts.authorId,
postCount: count(posts.id),
})
.from(posts)
.groupBy(posts.authorId)
.having(gt(count(posts.id), 5));
// Insert
const [newUser] = await db
.insert(users)
.values({ email: 'jane@example.com', name: 'Jane' })
.returning();
// Update
await db
.update(users)
.set({ name: 'Jane Smith' })
.where(eq(users.id, 1));
// Delete
await db.delete(users).where(eq(users.id, 1));
// Relational queries (the "magic" API)
const usersWithRelations = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.published, true),
limit: 5,
},
},
});
// Transactions
await db.transaction(async (tx) => {
await tx.insert(users).values({ email: 'bob@example.com', name: 'Bob' });
await tx.insert(posts).values({ title: 'Hello', authorId: 1 });
});
Drizzle Migrations
# Generate migration from schema changes
npx drizzle-kit generate
# Apply migrations
npx drizzle-kit migrate
# Push schema directly (dev only, no migration file)
npx drizzle-kit push
# Launch Drizzle Studio (web UI)
npx drizzle-kit studio
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
Why People Love Drizzle
- SQL-like API — if you know SQL, you know Drizzle. No new query language to learn
- Tiny bundle — no code generation step, no engine binary
- Fast — minimal abstraction means minimal overhead
- TypeScript-first — schema IS your types, no generation needed
- Serverless-friendly — lightweight, fast cold starts
- Supports edge runtimes — works in Cloudflare Workers, Vercel Edge
Where Drizzle Falls Short
- Younger ecosystem, fewer tutorials and Stack Overflow answers
- Relational queries API can feel limited compared to Prisma's
include - Schema-as-code can get verbose for large schemas
- Migration tooling is less polished than Prisma's
Sequelize — The Veteran
Sequelize has been around since 2011. It's the most mature ORM in the Node.js ecosystem and still powers a lot of production apps. It supports PostgreSQL, MySQL, MariaDB, SQLite, and SQL Server.
Model Definition
// models/User.js
const { DataTypes, Model } = require('sequelize');
class User extends Model {
// Instance methods
getFullName() {
return `${this.firstName} ${this.lastName}`;
}
// Class methods
static async findActiveUsers() {
return this.findAll({ where: { active: true } });
}
}
User.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
firstName: {
type: DataTypes.STRING,
allowNull: false,
validate: {
len: [2, 50],
},
},
lastName: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
validate: {
isEmail: true,
},
},
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user',
},
}, {
sequelize, // connection instance
modelName: 'User',
tableName: 'users',
timestamps: true, // adds createdAt, updatedAt
paranoid: true, // soft delete (adds deletedAt)
});
// Associations
User.hasMany(Post, { foreignKey: 'authorId', as: 'posts' });
User.hasOne(Profile, { foreignKey: 'userId', as: 'profile' });
Post.belongsTo(User, { foreignKey: 'authorId', as: 'author' });
Queries
// Find all with eager loading
const users = await User.findAll({
where: {
role: 'admin',
createdAt: {
[Op.gte]: new Date('2026-01-01'),
},
},
include: [
{ model: Post, as: 'posts', where: { published: true } },
{ model: Profile, as: 'profile' },
],
order: [['createdAt', 'DESC']],
limit: 10,
offset: 0,
});
// Create with association
const user = await User.create({
firstName: 'Jane',
lastName: 'Doe',
email: 'jane@example.com',
profile: { bio: 'Developer' },
}, {
include: [{ model: Profile, as: 'profile' }],
});
// Transactions
const t = await sequelize.transaction();
try {
const user = await User.create({ /* ... */ }, { transaction: t });
await Post.create({ authorId: user.id, /* ... */ }, { transaction: t });
await t.commit();
} catch (error) {
await t.rollback();
}
Migrations with Sequelize CLI
npx sequelize-cli migration:generate --name add-user-role
// migrations/20260305-add-user-role.js
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'role', {
type: Sequelize.ENUM('user', 'admin'),
defaultValue: 'user',
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('users', 'role');
},
};
Sequelize: Pros and Cons
Pros:
- Battle-tested in production for 13+ years
- Extensive feature set (scopes, hooks, validators, paranoid mode)
- Huge community and Stack Overflow knowledge base
- Supports the most databases
Cons:
- TypeScript support is bolted on, not native — types often feel clunky
- API can be inconsistent and surprising
- Documentation has gaps
- Heavy — lots of abstractions and legacy patterns
- Not ideal for new TypeScript projects
TypeORM — The Decorator-Based ORM
TypeORM was inspired by Java's Hibernate and C#'s Entity Framework. It uses TypeScript decorators heavily and supports both Active Record and Data Mapper patterns.
Entity Definition
import {
Entity, PrimaryGeneratedColumn, Column,
OneToMany, ManyToOne, CreateDateColumn,
UpdateDateColumn, Index,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column({ nullable: true })
name: string;
@Column({ type: 'enum', enum: ['user', 'admin'], default: 'user' })
role: string;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@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)
author: User;
@Column()
@Index()
authorId: number;
}
Query Patterns
import { AppDataSource } from './data-source';
const userRepo = AppDataSource.getRepository(User);
// Find with relations
const users = await userRepo.find({
where: { role: 'admin' },
relations: { posts: true },
order: { createdAt: 'DESC' },
take: 10,
});
// QueryBuilder — more complex queries
const users = await userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.role = :role', { role: 'admin' })
.andWhere('post.published = :published', { published: true })
.orderBy('user.createdAt', 'DESC')
.getMany();
// Active Record pattern (extend BaseEntity)
@Entity()
export class User extends BaseEntity {
// ... columns
}
// Then:
const user = await User.findOneBy({ id: 1 });
await User.save(newUser);
TypeORM: Pros and Cons
Pros:
- Familiar if you come from Java/C# (Hibernate/EF patterns)
- Supports both Active Record and Data Mapper
- QueryBuilder is powerful for complex queries
- Good migration support
Cons:
- Decorator syntax requires
experimentalDecoratorsandemitDecoratorMetadata - TypeScript types can be unreliable (relations are sometimes
undefinedat runtime even though types say otherwise) - Development has been inconsistent — long periods without updates
- Known bugs that go unfixed for months
- Many developers are migrating away to Prisma or Drizzle
Knex.js — The Query Builder
Knex isn't an ORM. It's a query builder. No models, no relations, no entity mapping. Just a nice way to construct SQL queries with JavaScript.
Why It Exists
Sometimes you don't need the full ORM abstraction. You just want to write SQL without string concatenation and with proper parameterization.
import knex from 'knex';
const db = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10 },
});
// Select
const users = await db('users')
.select('users.*', db.raw('COUNT(posts.id) as post_count'))
.leftJoin('posts', 'users.id', 'posts.author_id')
.where('users.role', 'admin')
.groupBy('users.id')
.orderBy('post_count', 'desc')
.limit(10);
// Insert
const [id] = await db('users')
.insert({ email: 'jane@example.com', name: 'Jane' })
.returning('id');
// Transaction
await db.transaction(async (trx) => {
const [userId] = await trx('users')
.insert({ email: 'bob@example.com' })
.returning('id');
await trx('posts')
.insert({ title: 'First Post', author_id: userId });
});
Knex Migrations
npx knex migrate:make create_users_table
// migrations/20260305_create_users.js
exports.up = function(knex) {
return knex.schema.createTable('users', (table) => {
table.increments('id').primary();
table.string('email').unique().notNullable();
table.string('name');
table.enum('role', ['user', 'admin']).defaultTo('user');
table.timestamps(true, true); // created_at, updated_at
});
};
exports.down = function(knex) {
return knex.schema.dropTable('users');
};
When Knex Is Enough
- You're comfortable with SQL and just want parameterization + migration tools
- Your queries are complex and ORMs get in the way
- You want a thin layer over SQL without learning an ORM's query language
- You're building a library or framework and want minimal opinions
Raw SQL — When to Skip the ORM Entirely
Sometimes the best ORM is no ORM.
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Parameterized query (safe from SQL injection)
const result = await pool.query(
`SELECT u.id, u.name, COUNT(p.id) as post_count,
ARRAY_AGG(DISTINCT t.name) as tags
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
LEFT JOIN post_tags pt ON pt.post_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id
WHERE u.created_at > $1
AND p.published = true
GROUP BY u.id
HAVING COUNT(p.id) > $2
ORDER BY post_count DESC
LIMIT $3`,
[startDate, minPosts, limit]
);
const users = result.rows;
When Raw SQL Makes Sense
- Complex analytics queries — window functions, CTEs, recursive queries
- Performance-critical paths — you need to control exactly what SQL runs
- Database-specific features — full-text search, JSONB operators, geospatial queries
- Bulk operations — inserting/updating thousands of rows efficiently
- You're a SQL expert — and an ORM's abstractions slow you down
When Raw SQL Is a Bad Idea
- Team has varied SQL experience levels
- You need migrations and schema management
- You want type safety on query results
- You're building CRUD endpoints (ORMs excel here)
Head-to-Head Comparison
| Feature | Prisma | Drizzle | Sequelize | TypeORM | Knex |
|---|---|---|---|---|---|
| Type safety | Excellent (generated) | Excellent (inferred) | Poor (bolted on) | Okay (decorators) | Manual |
| Bundle size | Large (Rust engine) | Small (~50KB) | Medium | Medium | Small |
| Performance | Good* | Excellent | Good | Good | Excellent |
| Migration tools | Excellent | Good | Good | Good | Good |
| Learning curve | Low | Low-Medium | Medium | Medium-High | Low |
| Raw SQL escape hatch | Yes ($queryRaw) |
Yes (sql) |
Yes (sequelize.query) |
Yes (query) |
Native |
| Databases | PG, MySQL, SQLite, Mongo, MSSQL, CockroachDB | PG, MySQL, SQLite | PG, MySQL, MariaDB, SQLite, MSSQL | PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, CockroachDB | PG, MySQL, SQLite, MSSQL, Oracle |
| Active Record | No | No | Yes | Yes | No |
| Data Mapper | Yes (Prisma Client) | Yes | No | Yes | N/A |
| Serverless/Edge | Okay (Accelerate) | Excellent | Poor | Poor | Good |
| DevTools/Studio | Prisma Studio | Drizzle Studio | No | No | No |
| Schema definition |
.prisma file |
TypeScript | JavaScript/TS | Decorators | JS migrations |
| Community size | Very large | Growing fast | Very large | Large | Large |
| Maturity | Stable (since 2019) | Young (since 2022) | Battle-tested (2011) | Mature (2016) | Battle-tested (2013) |
| Stars (GitHub) | 40k+ | 27k+ | 29k+ | 34k+ | 19k+ |
*Prisma's Rust engine adds a small overhead for simple queries but is highly optimized for complex ones.
The N+1 Query Problem
This is the most common performance pitfall with ORMs. Understanding it is essential.
What It Is
You want: All users and their posts
N+1 Approach (BAD):
Query 1: SELECT * FROM users -- 1 query
Query 2: SELECT * FROM posts WHERE author_id = 1 -- +N queries
Query 3: SELECT * FROM posts WHERE author_id = 2 -- (one per user)
Query 4: SELECT * FROM posts WHERE author_id = 3
...
Query N+1: SELECT * FROM posts WHERE author_id = N
Result: 1 + N queries. If you have 100 users, that's 101 queries.
Proper Approach (GOOD):
Query 1: SELECT * FROM users
Query 2: SELECT * FROM posts WHERE author_id IN (1, 2, 3, ..., N)
Result: 2 queries. Always.
How Each ORM Handles It
Prisma — uses include which generates efficient IN queries:
const users = await prisma.user.findMany({
include: { posts: true },
});
// Generates: SELECT * FROM users
// SELECT * FROM posts WHERE author_id IN (1, 2, 3, ...)
Drizzle — relational queries handle it; raw select requires manual joins:
// Relational API (handles N+1)
const users = await db.query.users.findMany({
with: { posts: true },
});
// Select API (you handle it)
const users = await db
.select()
.from(usersTable)
.leftJoin(postsTable, eq(usersTable.id, postsTable.authorId));
Sequelize — include with eager loading:
const users = await User.findAll({
include: [{ model: Post, as: 'posts' }],
});
TypeORM — relations option or leftJoinAndSelect in QueryBuilder:
const users = await userRepo.find({
relations: { posts: true },
});
Knex/Raw SQL — you write the JOIN yourself. Full control, full responsibility.
Migration Strategies
Schema-First (Prisma)
Edit the schema file, generate a migration. The tool figures out the diff.
Edit schema.prisma --> prisma migrate dev --> migration.sql (auto-generated)
Pro: Single source of truth. Hard to get out of sync.
Con: Less control over the exact SQL in the migration.
Code-First (Drizzle, TypeORM)
Define your schema in TypeScript, generate migrations from the diff.
Edit schema.ts --> drizzle-kit generate --> migration.sql (auto-generated)
Pro: Schema lives in your application code, no separate DSL.
Con: Schema files can get long and hard to read.
Manual Migrations (Knex, Sequelize)
Write migration files by hand — both up and down.
Create migration file --> Write SQL/JS manually --> Apply with CLI
Pro: Complete control over every migration.
Con: Easy to forget steps. Schema and migrations can drift.
Migration Best Practices
-
Always write
downmigrations — you'll need to rollback eventually - Never edit a migration that's been applied in production — create a new one
- Make migrations backward-compatible when possible (add columns as nullable first, then backfill, then add constraints)
- Test migrations on a copy of production data — the staging DB should have realistic data volumes
- Use transactions — a failed migration shouldn't leave the database in a half-migrated state
Performance: When ORMs Hurt
ORMs add overhead. Most of the time it doesn't matter. Sometimes it does.
Where ORMs Struggle
-
Bulk inserts — inserting 10,000 rows one at a time through an ORM is orders of magnitude slower than a single
INSERT INTO ... VALUESstatement
// SLOW — 10,000 individual INSERT statements
for (const item of items) {
await prisma.item.create({ data: item });
}
// FAST — single bulk insert
await prisma.item.createMany({ data: items });
// FASTEST — raw SQL with COPY or multi-row INSERT
await prisma.$executeRaw`
INSERT INTO items (name, value)
SELECT * FROM unnest(${names}::text[], ${values}::int[])
`;
Complex aggregations — ORMs generate suboptimal SQL for window functions, CTEs, and complex GROUP BY
Large result sets — ORMs hydrate every row into an object. For millions of rows, use streaming or raw SQL
Database-specific features — full-text search, JSONB operators, geospatial queries often need raw SQL
Measuring the Overhead
// Enable Prisma query logging
const prisma = new PrismaClient({
log: [
{ level: 'query', emit: 'event' },
],
});
prisma.$on('query', (e) => {
console.log(`Query: ${e.query}`);
console.log(`Duration: ${e.duration}ms`);
});
// Drizzle — enable logging
const db = drizzle(pool, { logger: true });
Always measure before optimizing. In most CRUD applications, the ORM overhead is negligible compared to network latency and actual query execution time.
Real-World Usage
What are real companies and frameworks actually using?
| Company/Framework | ORM/Query Layer | Notes |
|---|---|---|
| Vercel / Next.js examples | Prisma, Drizzle | Prisma in older examples, Drizzle in newer ones |
| Planetscale | Prisma, Drizzle | Their docs feature both prominently |
| T3 Stack | Prisma (default), Drizzle | Community split, Drizzle gaining ground |
| Payload CMS | Drizzle | Migrated from Mongoose to Drizzle |
| RedwoodJS | Prisma | Deeply integrated |
| Wasp | Prisma | Core of their stack |
| Remix / Epic Stack | Prisma | Kent C. Dodds' recommendation |
| Nuxt | Drizzle (Nuxt Hub) | Server-first approach aligns well |
| Large enterprises | Sequelize, TypeORM, Knex | Legacy codebases, still maintained |
| Startups (2024+) | Prisma or Drizzle | The modern default choices |
Decision Framework
START
|
v
Are you using TypeScript?
|
YES NO
| |
v v
Do you want maximum Sequelize or Knex
type safety? (best JS-only options)
|
YES
|
v
Do you prefer a schema DSL Do you prefer
or TypeScript-native schema? SQL-like syntax?
| |
DSL (Prisma) TS-native
| |
v v
+------------------+ +------------------+
| Use PRISMA | | Use DRIZZLE |
| if you want: | | if you want: |
| - Best DX | | - SQL-like API |
| - Auto migrations| | - Tiny bundle |
| - Prisma Studio | | - Edge/serverless|
| - Largest | | - Max performance|
| ecosystem | | - No code gen |
+------------------+ +------------------+
Special cases:
|
+-- Need a query builder only? --> Knex.js
+-- Existing codebase with Sequelize? --> Stay or migrate gradually
+-- Performance-critical analytics? --> Raw SQL + types (e.g., pgTyped)
+-- Complex domain with many relations? --> Prisma
+-- Simple CRUD API? --> Either Prisma or Drizzle
+-- Deploying to edge/serverless? --> Drizzle (or Prisma with Accelerate)
The Quick Answer
| Scenario | Recommendation |
|---|---|
| New TypeScript project | Prisma or Drizzle |
| Love writing SQL | Drizzle or Knex |
| Want best developer experience | Prisma |
| Serverless / edge runtime | Drizzle |
| Existing JavaScript codebase | Sequelize (or migrate to Prisma) |
| Complex analytics queries | Raw SQL + Knex for simpler stuff |
| Enterprise with Java/C# devs | TypeORM (familiar patterns) |
| Just need migrations | Knex |
| Maximum performance | Drizzle or Raw SQL |
Combination Patterns That Work Well
You don't have to pick just one. Many production apps combine approaches:
Pattern 1: Prisma + Raw SQL
- Prisma for 90% of CRUD operations
- Raw SQL ($queryRaw) for complex reports and analytics
Pattern 2: Drizzle + Knex
- Drizzle for typed queries and schema
- Knex for complex dynamic query building
Pattern 3: ORM + Database Views
- Use the ORM for writes
- Create database views for complex reads
- Map the ORM to views for read queries
Wrapping Up
The ORM landscape in the Node.js ecosystem has matured significantly. You no longer have to choose between "developer experience" and "performance" — tools like Drizzle prove you can have both. And Prisma continues to set the standard for type safety and developer productivity.
Here's what I'd tell someone starting fresh today:
- Default to Prisma if you want the most mature, well-documented, batteries-included experience
- Choose Drizzle if you value SQL familiarity, lightweight bundles, and edge compatibility
- Keep raw SQL in your toolkit for the 10% of queries that ORMs make awkward
- Don't let the ORM choice paralyze you — all the modern options are good. Pick one and build something
The best ORM is the one your team can use effectively. Everything else is a benchmark.
If you found this helpful, let's connect! I write about backend engineering, databases, and developer tooling.
Top comments (0)