DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

ORMs 101: The Ultimate Guide

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.
Enter fullscreen mode Exit fullscreen mode

What an ORM Does

  1. Schema mapping — defines how your objects correspond to tables
  2. Query generation — translates method calls into SQL
  3. Result hydration — converts database rows back into objects with relationships
  4. Migration management — tracks and applies schema changes over time
  5. 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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Parses your query object into an AST (Abstract Syntax Tree)
  2. Translates the AST into a SQL string: SELECT * FROM users WHERE age > 18
  3. Parameterizes values to prevent SQL injection: SELECT * FROM users WHERE age > $1 with params [18]
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Migration Flow
===============

schema.prisma (edit) --> prisma migrate dev --> SQL migration file
                                                    |
                                                    v
                                            prisma/migrations/
                                              20260301_init/
                                                migration.sql
                                              20260305_add_role/
                                                migration.sql
Enter fullscreen mode Exit fullscreen mode

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 },
  });
});
Enter fullscreen mode Exit fullscreen mode

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}
`;
Enter fullscreen mode Exit fullscreen mode

Prisma Studio

npx prisma studio
# Opens a web UI at localhost:5555 for browsing/editing data
Enter fullscreen mode Exit fullscreen mode

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_modules size
  • 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],
  }),
}));
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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!,
  },
});
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Migrations with Sequelize CLI

npx sequelize-cli migration:generate --name add-user-role
Enter fullscreen mode Exit fullscreen mode
// 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');
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 experimentalDecorators and emitDecoratorMetadata
  • TypeScript types can be unreliable (relations are sometimes undefined at 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 });
});
Enter fullscreen mode Exit fullscreen mode

Knex Migrations

npx knex migrate:make create_users_table
Enter fullscreen mode Exit fullscreen mode
// 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');
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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, ...)
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Sequelizeinclude with eager loading:

const users = await User.findAll({
  include: [{ model: Post, as: 'posts' }],
});
Enter fullscreen mode Exit fullscreen mode

TypeORMrelations option or leftJoinAndSelect in QueryBuilder:

const users = await userRepo.find({
  relations: { posts: true },
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pro: Complete control over every migration.
Con: Easy to forget steps. Schema and migrations can drift.

Migration Best Practices

  1. Always write down migrations — you'll need to rollback eventually
  2. Never edit a migration that's been applied in production — create a new one
  3. Make migrations backward-compatible when possible (add columns as nullable first, then backfill, then add constraints)
  4. Test migrations on a copy of production data — the staging DB should have realistic data volumes
  5. 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

  1. Bulk inserts — inserting 10,000 rows one at a time through an ORM is orders of magnitude slower than a single INSERT INTO ... VALUES statement
// 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[])
`;
Enter fullscreen mode Exit fullscreen mode
  1. Complex aggregations — ORMs generate suboptimal SQL for window functions, CTEs, and complex GROUP BY

  2. Large result sets — ORMs hydrate every row into an object. For millions of rows, use streaming or raw SQL

  3. 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 });
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Connect with me on LinkedIn

Top comments (0)