Web Engineering ·

Drizzle vs Prisma vs TypeORM: Choosing a TypeScript ORM for Production

A practical comparison of Drizzle, Prisma, and TypeORM across migration story, query builder ergonomics, type safety, raw SQL escape hatches, serverless connection pooling, and performance. Real TypeScript code showing the same operations in all three, with a tradeoffs table and guidance for startup teams.

Drizzle vs Prisma vs TypeORM: Choosing a TypeScript ORM for Production

Picking a TypeScript ORM is one of those decisions that feels minor until it is not. You are usually fine for the first six months, but then you hit a migration edge case, a Prisma query that generates a cartesian join, or TypeORM decorators that fight you every time you try strict mode. The choice matters.

This is not a benchmark post with synthetic read loops. It is a practical comparison across the things that actually cause pain in production: migration workflows, query ergonomics, type safety fidelity, raw SQL access, serverless connection behavior, and what breaks at scale.

All three ORMs are viable. The question is which tradeoffs you want to carry.


The Setup: Same Schema, Three Tools

To keep comparisons honest, every code example uses the same domain: a users table and an orders table with a foreign key. Simple enough to be readable, realistic enough to expose real differences.

-- users: id, email, created_at
-- orders: id, user_id, total, status, created_at

Schema Definition and Migration Story

This is where the three tools diverge most sharply.

Prisma

Prisma uses its own schema language (PSL). You define models in schema.prisma, run prisma migrate dev to generate SQL migrations, and commit those migration files. The workflow is clean and the generated SQL is readable.

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  createdAt DateTime @default(now())
  orders    Order[]
}

model Order {
  id        Int      @id @default(autoincrement())
  userId    Int
  total     Decimal
  status    String
  createdAt DateTime @default(now())
  user      User     @relation(fields: [userId], references: [id])
}

The migration system is well-thought-out. prisma migrate dev creates timestamped SQL files you commit alongside your code. prisma migrate deploy applies them in CI. Shadow databases handle drift detection. For teams that want a single source of truth and do not want to write SQL migrations by hand, this is genuinely good.

The cost: any schema change requires regenerating the Prisma client. In a monorepo with multiple services importing @prisma/client, a schema change ripples further than it should.

Drizzle

Drizzle defines schema in TypeScript directly. No DSL, no code generation step at import time. The schema file is just TypeScript that you import anywhere.

import { pgTable, serial, text, timestamp, numeric, integer } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const orders = pgTable("orders", {
  id: serial("id").primaryKey(),
  userId: integer("user_id")
    .notNull()
    .references(() => users.id),
  total: numeric("total", { precision: 10, scale: 2 }).notNull(),
  status: text("status").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Migrations are generated via drizzle-kit generate, which diffs your schema against the current database state and produces SQL files. You still commit those SQL files. The difference from Prisma is that your schema doubles as your type source. No separate codegen step, no generated file to .gitignore.

For teams that are comfortable in TypeScript and want their schema to be a first-class module, Drizzle’s approach scales better across a large codebase.

TypeORM

TypeORM uses decorator-based entity classes, which means you need experimentalDecorators and emitDecoratorMetadata in your tsconfig.json. Both are footguns in strict TypeScript projects.

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, OneToMany } from "typeorm";

@Entity("users")
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @CreateDateColumn()
  createdAt: Date;

  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];
}

@Entity("orders")
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => User, (user) => user.orders)
  user: User;

  @Column("decimal", { precision: 10, scale: 2 })
  total: string; // Decimal columns come back as strings

  @Column()
  status: string;

  @CreateDateColumn()
  createdAt: Date;
}

TypeORM’s migration system is functional but fragile. typeorm migration:generate diffs your entities against the database, but the output requires careful review. Rename a column and it will generate a DROP + ADD instead of an ALTER. That behavior has burned teams in production.


Query Builder Ergonomics

Fetching users with their recent orders

Prisma:

const users = await prisma.user.findMany({
  where: { orders: { some: { status: "pending" } } },
  include: {
    orders: {
      where: { status: "pending" },
      orderBy: { createdAt: "desc" },
      take: 5,
    },
  },
});

Prisma’s query API is the most readable of the three. Autocomplete works well, and the nested filter/include pattern is intuitive. The tradeoff is that complex queries often generate surprising SQL. A findMany with nested include on a large dataset can generate N+1 queries or a single query with an inefficient join. You need to check prisma.$queryRaw output to be sure.

Drizzle:

import { eq, desc } from "drizzle-orm";

const result = await db
  .select({
    userId: users.id,
    email: users.email,
    orderId: orders.id,
    total: orders.total,
    status: orders.status,
  })
  .from(users)
  .innerJoin(orders, eq(orders.userId, users.id))
  .where(eq(orders.status, "pending"))
  .orderBy(desc(orders.createdAt))
  .limit(50);

Drizzle’s query builder maps almost 1:1 to SQL. If you know SQL, you know what query this generates because it is essentially SQL spelled in TypeScript. That predictability is its core value proposition. You will not be surprised by the generated query because you basically wrote it.

The ergonomics are more verbose than Prisma for simple cases, but they do not degrade as queries get more complex. A five-table join with conditional aggregates is just more SQL, not a puzzle of nested Prisma options.

TypeORM:

const users = await dataSource
  .getRepository(User)
  .createQueryBuilder("user")
  .innerJoinAndSelect("user.orders", "order", "order.status = :status", { status: "pending" })
  .orderBy("order.createdAt", "DESC")
  .take(50)
  .getMany();

TypeORM’s query builder is string-based, which means no compile-time safety on column names or relation paths. "user.orders" is a string. If you rename the relation, the query breaks at runtime. This is the fundamental type safety problem with TypeORM, and it is hard to work around without writing custom abstractions.


Type Safety Fidelity

This is where Drizzle wins cleanly.

Prisma generates types from your schema, but those types are defined in a generated file in node_modules. Optional relations come back as User & { orders?: Order[] }, which means you have to handle optional properties even when you explicitly included them in the query. There is a known structural issue where include return types are not always narrowed correctly in complex queries.

Drizzle infers types directly from your schema definition. The result of a select with specific columns is exactly typed to those columns. No generated files, no inference surprises. If you select { userId: users.id, email: users.email }, the result type is { userId: number; email: string }[].

// Drizzle: result type is inferred precisely
const result = await db.select({ id: users.id, email: users.email }).from(users);
// typeof result = { id: number; email: string }[]

TypeORM’s type safety is the weakest. Because relation paths are strings and entity decorators rely on runtime metadata, the TypeScript types are often wider than the actual runtime shape. You routinely get User | undefined where you know the value must be present.


Raw SQL Escape Hatches

Every ORM needs a clean way to drop to raw SQL. This comes up constantly: complex window functions, lateral joins, database-specific features, performance-critical queries.

Prisma:

const result = await prisma.$queryRaw<{ email: string; order_count: number }[]>`
  SELECT u.email, COUNT(o.id)::int AS order_count
  FROM users u
  LEFT JOIN orders o ON o.user_id = u.id
  GROUP BY u.id
  HAVING COUNT(o.id) > 5
`;

Prisma’s $queryRaw uses tagged template literals, which automatically parameterize values. Safe and ergonomic.

Drizzle:

import { sql } from "drizzle-orm";

const result = await db.execute<{ email: string; order_count: number }>(
  sql`SELECT u.email, COUNT(o.id)::int AS order_count
      FROM users u
      LEFT JOIN orders o ON o.user_id = u.id
      GROUP BY u.id
      HAVING COUNT(o.id) > 5`
);

Drizzle’s sql tagged template also parameterizes values. The difference is that you can compose sql fragments inside Drizzle query builders, which enables conditional query construction without string concatenation.

TypeORM:

const result = await dataSource.query<{ email: string; order_count: string }>(
  `SELECT u.email, COUNT(o.id) AS order_count
   FROM users u
   LEFT JOIN orders o ON o.user_id = u.id
   GROUP BY u.id
   HAVING COUNT(o.id) > $1`,
  [5]
);

TypeORM’s raw query accepts a string and a separate parameters array. It works, but it is more error-prone than tagged templates. Also note that COUNT returns a string in TypeORM’s typing, matching PostgreSQL’s wire format directly.


Connection Pooling in Serverless

All three tools assume persistent connections by default. In serverless environments (AWS Lambda, Cloudflare Workers, Vercel Edge), that assumption breaks. Every function invocation potentially opens a new connection, and you can exhaust your database’s connection limit quickly.

Prisma has PgBouncer support and Prisma Accelerate as a managed connection pooler. If you are on Prisma, the serverless story requires using their accelerate product or configuring PgBouncer yourself and setting ?pgbouncer=true in the connection string.

Drizzle is transport-agnostic. You bring your own driver. For serverless, you use @neondatabase/serverless, postgres.js with pool disabled, or a pooling proxy. Drizzle does not add connection management overhead.

// Drizzle with Neon serverless driver
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
// Each call goes over HTTP, no persistent connection

TypeORM requires configuring the connection pool size explicitly and using a pooler like PgBouncer in front of the database. The decorator-based entity loading also uses reflect-metadata, which has compatibility issues in some serverless runtimes.

For serverless specifically: Drizzle is the best fit because you own the connection strategy. Prisma is workable with Accelerate. TypeORM is the most friction.


Tradeoffs Table

DrizzlePrismaTypeORM
Type safetyExcellent (inferred)Good (generated)Fair (strings in queries)
Query predictabilityExcellent (1:1 SQL)Moderate (implicit joins)Good (explicit builder)
Migration toolingGoodExcellentModerate
Serverless fitExcellentGood (with Accelerate)Poor
Bundle sizeSmall (~50KB)Large (Rust engine)Moderate
Raw SQL ergonomicsExcellent (composable)GoodFair
Learning curveLow (SQL knowledge transfers)Low (good docs)Moderate (decorators, DI)
Ecosystem maturityYoung (2022+)Mature (2018+)Old (2016+)
Strict TypeScriptNo issuesMinor issuesRequires legacy flags

Production Considerations for Startup Teams

Migration safety: All three generate SQL migration files that you should commit and review before applying. Never run auto-sync against production (synchronize: true in TypeORM, db push in Prisma outside local dev). Treat migrations as database code changes, not side effects of a deploy.

N+1 queries: Prisma’s include can generate N+1 queries if relations are fetched in a loop outside of the initial query. Use findMany with include rather than loading relations per-entity. Drizzle makes this less likely because you are writing the join yourself. TypeORM requires explicit relations or leftJoinAndSelect to avoid N+1.

Schema drift in teams: Multiple engineers changing the schema simultaneously is where migration tooling matters most. Prisma’s shadow database approach handles conflicts reasonably well. Drizzle and TypeORM both require manual conflict resolution in migration SQL files.

Observability: Log the generated SQL in development. Prisma has log: ['query'] in the client constructor. Drizzle accepts a logger option. TypeORM has logging: true. Run EXPLAIN ANALYZE on any query that touches more than 10K rows before shipping it.

When to choose Drizzle: You want predictable queries, maximum type safety, and control over connection management. Particularly good for teams comfortable with SQL, serverless deployments, or edge runtimes.

When to choose Prisma: You want excellent DX, strong migration tooling, and are willing to accept a larger dependency and occasional query inspection. Good fit for teams moving fast and deploying to traditional Node.js servers or PaaS platforms.

When to choose TypeORM: You are maintaining an existing TypeORM codebase, or you specifically need the active record pattern and are not on a strict TypeScript configuration. Starting a new project with TypeORM in 2026 is difficult to justify given the alternatives.


Closing Thought

The ORM question is really two questions: how much SQL do you want to write, and how much do you trust the abstraction. Prisma bets you will trade control for DX. Drizzle bets you would rather write SQL with types. TypeORM predates the modern TypeScript ecosystem and shows it.

For most new TypeScript projects, the choice is between Drizzle and Prisma. If your team knows SQL and you care about predictable query plans, go Drizzle. If you want the fastest path to a working data layer with good docs, go Prisma. Either way, audit the generated SQL before you hit production.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.