Three Approaches to Database Access

The Node.js ORM landscape has shifted dramatically. TypeORM was the TypeScript ORM for years, Prisma disrupted the space with its schema-first approach and code generation, and Drizzle arrived with a promise: type-safe SQL that feels like writing SQL.

Each represents a different philosophy about how application code should interact with databases. TypeORM follows the Active Record and Data Mapper patterns familiar from Java and .NET. Prisma introduces its own schema language and generates a type-safe client. Drizzle stays close to SQL while providing full TypeScript inference.

We built the same REST API — a project management tool with users, projects, tasks, and comments — using all three ORMs against PostgreSQL. The codebase, benchmarks, and migration files are on GitHub.

Schema Definition

How you define your data model sets the tone for everything else.

Prisma uses its own schema language in a .prisma file:

// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  tasks     Task[]
  createdAt DateTime @default(now())
}

model Task {
  id          Int      @id @default(autoincrement())
  title       String
  status      Status   @default(TODO)
  assignee    User?    @relation(fields: [assigneeId], references: [id])
  assigneeId  Int?
}

enum Status {
  TODO
  IN_PROGRESS
  DONE
}

Drizzle defines schemas in TypeScript:

// schema.ts
import { pgTable, serial, text, varchar, integer, timestamp, pgEnum } from 'drizzle-orm/pg-core';

export const statusEnum = pgEnum('status', ['todo', 'in_progress', 'done']);

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

export const tasks = pgTable('tasks', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  status: statusEnum('status').default('todo').notNull(),
  assigneeId: integer('assignee_id').references(() => users.id),
});

TypeORM uses decorators on classes:

// entities/User.ts
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

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

  @Column({ nullable: true })
  name: string;

  @OneToMany(() => Task, task => task.assignee)
  tasks: Task[];

  @CreateDateColumn()
  createdAt: Date;
}

Prisma's schema language is clean and readable but requires learning a new DSL. Drizzle keeps everything in TypeScript, which means your IDE autocompletion and refactoring tools work on your schema. TypeORM's decorator approach is familiar to anyone who has used decorators in Java or C#, but TypeScript decorators have historically been unstable (the TC39 proposal changed multiple times), and TypeORM still relies on the legacy experimental decorator syntax.

Query Patterns

Here is a moderately complex query: find all tasks with their assignees where the status is "in_progress", ordered by creation date.

Prisma:

const tasks = await prisma.task.findMany({
  where: { status: 'IN_PROGRESS' },
  include: { assignee: true },
  orderBy: { createdAt: 'desc' },
});

Drizzle:

const result = await db
  .select()
  .from(tasks)
  .leftJoin(users, eq(tasks.assigneeId, users.id))
  .where(eq(tasks.status, 'in_progress'))
  .orderBy(desc(tasks.createdAt));

TypeORM:

const tasks = await taskRepo.find({
  where: { status: Status.IN_PROGRESS },
  relations: ['assignee'],
  order: { createdAt: 'DESC' },
});

For simple queries, all three are readable and concise. The differences emerge with complex queries.