What Drizzle, Prisma and TypeORM are and where the schema lives
Teams reach for these tools because hand-written SQL strings give the compiler nothing to check. A renamed column only fails at runtime, and row shapes are typed by hand. All three tools fix this by making one artefact the source of truth for the schema and deriving TypeScript types from it. They differ in what that artefact is and in what your build has to do to keep the types current.
Drizzle defines tables as TypeScript objects that map closely to SQL: columns, types, constraints and indexes. Row types are inferred from those definitions by the compiler, so nothing is generated. A NestJS service imports the table module and gets correct types immediately.
Prisma keeps the schema in a separate schema.prisma file written in its own DSL. prisma generate produces a typed client from it, and services import types from that generated output. The cost is that generation must run in every place that compiles the code: CI, Docker builds and fresh clones. If it is skipped, the build fails or, worse, the client no longer matches the schema.
TypeORM uses classes decorated with @Entity and @Column. The class is both the schema and the type. This model depends on experimentalDecorators and emitDecoratorMetadata in tsconfig. Any build tool that strips or ignores decorator metadata can break entity loading.
| Tool | Schema source of truth | Type generation | Build-time requirement |
|---|---|---|---|
| Drizzle | TypeScript table definitions | Inferred by compiler | None beyond tsc |
| Prisma | schema.prisma DSL file |
Generated client | prisma generate in CI and images |
| TypeORM | Decorated entity classes | Class is the type | Decorator compiler flags enabled |
// --- Drizzle: src/db/schema.ts ---
// The table definition is the source of truth. Types are inferred by tsc, with no codegen step.
import { pgTable, serial, text, timestamp } 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 type User = typeof users.$inferSelect; // row shape returned by SELECT
export type NewUser = typeof users.$inferInsert; // shape accepted by INSERT
// --- TypeORM: src/users/user.entity.ts ---
// The decorated class is both the schema and the type.
// Requires "experimentalDecorators": true and "emitDecoratorMetadata": true in tsconfig.
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ unique: true })
email!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
// --- Prisma: src/users/users.service.ts ---
// The User model lives in schema.prisma. This import resolves only after
// `prisma generate` has run. That includes CI, Docker builds and fresh clones.
// '@prisma/client' is the classic default output. Newer generator configs may
// write the client to a custom path set in schema.prisma.
import type { User as PrismaUser } from '@prisma/client';
The same users table in each tool. Drizzle infers types from a TypeScript table definition and TypeORM uses a decorated class as both schema and type. Prisma's types come from a client that prisma generate must produce before the code compiles.
How each tool plugs into a NestJS module
TypeORM is the only one of the three with an official Nest package. @nestjs/typeorm exposes TypeOrmModule.forRoot() (or forRootAsync() when config comes from ConfigService) to build a single DataSource, and forFeature([Entity]) to register repositories you inject with @InjectRepository(). The DataSource owns the pool through the underlying driver, and the integration module destroys it when the application shuts down. You configure pooling through driver options, but you never call destroy() yourself.
Prisma has no official Nest module. The usual pattern is a PrismaService that extends PrismaClient, calls $connect() in onModuleInit so connection failures surface at boot rather than on the first request, and calls $disconnect() on shutdown. Prisma manages the pool internally. Size it through the connection URL or the driver adapter. Count the replicas you run, because every replica opens its own pool.
Drizzle is a thin layer over a driver you construct. You create the pool (for example, a pg Pool), pass it to drizzle(), and expose the result under a custom injection token. Because you created the pool, closing it is your job: call pool.end() in onApplicationShutdown.
For all three, signal-driven hooks only fire if main.ts calls app.enableShutdownHooks(). Without it, a SIGTERM from the orchestrator kills the process while queries are still in flight.
HTTP ──▶ Controller ──▶ Service ──▶ ORM provider ──▶ Pool ──▶ Postgres
│ │
onModuleInit ─┤ connect / │
│ verify │
SIGTERM ─▶ enableShutdownHooks() │
onModuleDestroy / ─────────────┴─▶ disconnect / pool.end()
onApplicationShutdown
// drizzle.module.ts: Drizzle wrapped in a custom Nest provider.
// The app creates the pool, so the app must close it.
import { Global, Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
export const PG_POOL = Symbol('PG_POOL');
export const DRIZZLE = Symbol('DRIZZLE');
@Global()
@Module({
providers: [
{
// One pool per process. Multiply `max` by replica count for the total load on Postgres.
provide: PG_POOL,
useFactory: () =>
new Pool({ connectionString: process.env.DATABASE_URL, max: 10 }),
},
{
// Services inject this with @Inject(DRIZZLE) to run queries.
provide: DRIZZLE,
inject: [PG_POOL],
useFactory: (pool: Pool) => drizzle(pool),
},
],
exports: [DRIZZLE],
})
export class DrizzleModule implements OnApplicationShutdown {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
// Nest runs this after a signal only if main.ts calls app.enableShutdownHooks().
async onApplicationShutdown(signal?: string) {
await this.pool.end(); // waits for checked-out clients to be released, then closes all connections
}
}
// main.ts
// const app = await NestFactory.create(AppModule);
// app.enableShutdownHooks(); // without this, SIGTERM skips the hook above
// await app.listen(3000);
A global Nest module that builds a pg Pool, exposes a Drizzle instance under a custom injection token, and closes the pool in onApplicationShutdown, which runs on SIGTERM only when shutdown hooks are enabled.
The same query three ways: pagination, filtered join and column selection
The example lists page two of orders whose customer is active, returning only orders.id, orders.total and customers.name.
Drizzle
The SQL-like builder is the most literal: select({ id, total, name }), then .from(orders).innerJoin(customers, eq(...)).where(...).limit(20).offset(20). It produces one SELECT with an INNER JOIN and LIMIT/OFFSET, close to what you would write by hand. The relational API (db.query.orders.findMany with columns and with) nests the customer as an object. It typically compiles to one statement that uses JSON aggregation or lateral subqueries. The exact shape depends on the version, as does whether you can filter on the related table's columns.
Prisma
findMany takes where: { customer: { status: 'active' } }, a nested select, plus skip and take. The relation filter usually becomes a join or an EXISTS subquery. Loading the related fields has historically meant a second query, although newer releases can use a database-level join instead. Check the query log rather than assuming either strategy.
TypeORM
Find options (relations, select, where, skip, take) read cleanly. When joins are combined with skip/take, TypeORM may first run a DISTINCT query for the page of IDs and then a second query to fetch the rows. QueryBuilder (leftJoin, addSelect, where, limit/offset) gives explicit control over the SQL.
Type safety
Drizzle and Prisma infer the result type from the select, so reading an unselected field fails at compile time. TypeORM returns Order[] whatever you loaded, which means order.customer.email type-checks and then throws or yields undefined at runtime.
// Page 2 (20 per page) of orders whose customer is active: orders.id, orders.total, customers.name.
// Assumes schema/entities named orders/customers (Drizzle), Order/Customer (Prisma, TypeORM).
import { eq } from 'drizzle-orm';
// Drizzle SQL-like builder: one SELECT ... INNER JOIN ... LIMIT 20 OFFSET 20.
const d = await db
.select({ id: orders.id, total: orders.total, name: customers.name })
.from(orders)
.innerJoin(customers, eq(orders.customerId, customers.id))
.where(eq(customers.status, 'active'))
.orderBy(orders.id) // stable ordering is required for OFFSET pagination
.limit(20)
.offset(20);
// Inferred type: { id: number; total: string; name: string }[] (total's type depends on the column definition)
// Prisma: relation filter plus nested select. Inspect the query log for join vs. separate query.
const p = await prisma.order.findMany({
where: { customer: { status: 'active' } },
select: { id: true, total: true, customer: { select: { name: true } } },
orderBy: { id: 'asc' },
skip: 20,
take: 20,
});
// p[0].customer.email -> compile error: 'email' was not selected
// TypeORM QueryBuilder: explicit join. The joined primary key is included so the relation hydrates.
const t = await dataSource
.getRepository(Order)
.createQueryBuilder('o')
.innerJoin('o.customer', 'c')
.select(['o.id', 'o.total', 'c.id', 'c.name'])
.where('c.status = :status', { status: 'active' })
.orderBy('o.id', 'ASC')
.skip(20) // skip/take (not offset/limit) paginate by entity when joins are involved
.take(20)
.getMany();
// t is Order[]: t[0].customer.email type-checks but is undefined at runtime
The same paginated, filtered join with column selection in Drizzle's SQL-like builder, Prisma's nested select, and TypeORM's QueryBuilder. Drizzle and Prisma infer narrowed result types, while TypeORM returns full Order entities whose unselected fields are undefined at runtime.
Query performance: reasoning and inspection instead of benchmarks
Speed differences between these three tools are rarely what slows a NestJS API down. Most latency comes from the shape of the query, missing indexes and connection pool exhaustion. This article makes no measured speed claims about any of the three. A headline claim that one tool is some multiple faster than another means little unless it names the schema, data volume, driver, pool size and query it measured. Treat any such figure, including one you produce yourself, as illustrative of its assumed inputs rather than as a general property of the tool.
Where N+1 and over-fetching come from
- TypeORM: Lazy relations issue one query for each property you access, so a loop over orders causes N+1. Eager relations load on every
find*call, even when the caller doesn't need them. - Prisma: A nested
includecan run as several separate queries instead of a single join. The query log shows which one you got. - Drizzle: Joins are explicit, so N+1 only happens if you query inside a loop yourself.
In all three, loading whole entities pulls wide text and jsonb columns you never read. That makes covering indexes useless.
Inspect, then EXPLAIN
| Tool | Enable SQL logging | Raw SQL escape hatch | Safe parameterization | Result typing |
|---|---|---|---|---|
| Drizzle | logger: true in drizzle() |
sql template tag |
Interpolated values are bound. sql.raw() is not. |
sql<T> is an unchecked assertion |
| Prisma | log: ['query'] on the client |
$queryRaw, $executeRaw |
Tagged template binds values. $queryRawUnsafe does not. |
Generic T is an unchecked assertion |
| TypeORM | logging: ['query'] on the DataSource |
query(), QueryBuilder |
query(sql, params) with driver placeholders, and :name parameters in QueryBuilder |
query() returns any |
Copy the logged SQL into psql, fill in the bound parameters, and run EXPLAIN (ANALYZE, BUFFERS). Be careful with writes, because ANALYZE executes the statement.
Never concatenate user input into raw SQL. Once you drop to raw SQL, schema changes stop breaking the build, so validate the rows at runtime.
import { sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { PrismaClient } from '@prisma/client';
import type { DataSource } from 'typeorm';
import { Order } from './order.entity';
// The row shape we *claim* to get back. Nothing checks this at runtime.
type OrderRow = { id: number; status: string; total: string };
export async function recentOrders(
db: NodePgDatabase, prisma: PrismaClient, ds: DataSource, customerId: number,
) {
// Drizzle: ${customerId} becomes a bound parameter ($1), not spliced text.
// Never pass user input to sql.raw(), which inserts it verbatim.
const a = await db.execute(
sql`SELECT id, status, total FROM orders WHERE customer_id = ${customerId} ORDER BY created_at DESC LIMIT 20`,
);
// Prisma: the tagged template binds values. The <OrderRow[]> generic is an
// assertion only. $queryRawUnsafe(`... = ${customerId}`) would be injectable.
const b = await prisma.$queryRaw<OrderRow[]>`
SELECT id, status, total FROM orders WHERE customer_id = ${customerId}
ORDER BY created_at DESC LIMIT 20`;
// TypeORM query(): driver placeholders ($1 for Postgres) plus a params array.
// The return type is any, so we cast it, which is just as unchecked.
const c: OrderRow[] = await ds.query(
'SELECT id, status, total FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 20',
[customerId],
);
// TypeORM QueryBuilder: :name parameters are bound. select() limits columns,
// so wide text/jsonb fields are not fetched.
const d = await ds.getRepository(Order).createQueryBuilder('o')
.select(['o.id', 'o.status', 'o.total'])
.where('o.customerId = :customerId', { customerId })
.orderBy('o.createdAt', 'DESC').limit(20)
.getMany();
return { a, b, c, d };
}
The same narrow, parameterized query written with Drizzle's sql tag, Prisma's $queryRaw, TypeORM's query() and QueryBuilder, with comments marking the unsafe variants and the places where result typing turns into an unchecked assertion.
Migration workflows and running them safely in production
Once a schema is in production, the migration history becomes the main record of what the database actually looks like. Treat it that way: every generated file gets read, edited if needed, and committed alongside the code that depends on it.
| Tool | How migrations are produced | Development shortcut | Production apply step |
|---|---|---|---|
| Drizzle | Drizzle Kit diffs the TypeScript schema and writes SQL files (drizzle-kit generate) |
drizzle-kit push |
drizzle-kit migrate or the runtime migrator |
| Prisma | Prisma Migrate diffs schema.prisma and writes SQL (prisma migrate dev) |
prisma db push |
prisma migrate deploy |
| TypeORM | Diffs entities against the database and writes migration classes (migration:generate) |
synchronize: true |
migration:run |
Command names are stable, but flags and config file paths change between releases, so check them against current docs.
The push and synchronize shortcuts change the schema without leaving a reviewable history. Keep them on disposable local databases. Never point synchronize at a shared or production database, because a renamed entity property can silently drop a column.
In production, apply migrations as a separate pipeline step or one-off job, not in onModuleInit. If ten replicas boot at once, all ten race to apply the same migration. A dedicated job runs once, fails visibly, and blocks the rollout. Some tools take a database lock, but don't rely on that as your only protection.
Diff tools only see "column A gone, column B new". They often emit a drop plus an add where you meant a rename, which loses data. For renames and drops, use expand-and-contract:
- Add the new column.
- Dual-write and backfill.
- Switch reads to the new column.
- Drop the old column in a later release.
Transactions across NestJS service layers and what that means for tests
All three expose a callback-style transaction. Prisma's $transaction passes an interactive client tx to the callback. Drizzle's db.transaction passes a tx with the same query builder as db. TypeORM's dataSource.transaction passes an EntityManager. In each case, a resolved promise commits and a thrown error rolls back. The harder problem is getting that handle to the code that needs it when an order service calls an inventory service and both must write atomically.
Two common patterns
- Explicit parameter. Each repository method accepts an optional transaction handle and falls back to the default client. The data flow is visible and easy to follow, but the ORM's transaction type now appears in your method signatures.
- Ambient context. Code stores the handle in
AsyncLocalStorageat the start of a request or unit of work, and repositories read it from there. Signatures stay clean, but a call that escapes the async context silently runs outside the transaction.
Testing implications
Mock at your own repository interface, not at the ORM client. Mocking Prisma's generated client, Drizzle's builder chain or TypeORM's EntityManager produces brittle tests that check call shapes instead of behaviour.
For integration tests, run the real database engine in a container. Wrap each test in a transaction and roll it back at the end. This only works if the code under test uses the test's handle, which is straightforward with ambient context and requires threading the handle through every call with explicit parameters.
Switching cost
Every signature, helper and test fixture that names tx or EntityManager must change if you switch ORMs. Defining your own UnitOfWork abstraction keeps that change contained to one place.
Who should use which: decision guide, trade-offs and engineering cost
Choose by team profile rather than feature list. Each tool moves cost to a different part of the workflow.
| Tool | Fits | Avoid when |
|---|---|---|
| Drizzle | SQL-first teams that want a thin layer and SQL migration files reviewers can read | Less SQL-fluent developers need guardrails, or you can't absorb API changes in a younger library |
| Prisma | Teams that want a strongly typed high-level client and guided migration tooling | You need to see and tune every statement, can't add codegen to the build, or have tight deployment-size limits (check current engine behavior) |
| TypeORM | Existing TypeORM codebases, Active Record or Data Mapper designs, first-party Nest integration | You trust entity types to show what was loaded (unloaded relations still type as present), or won't hand-review generated migrations |
If none of these fits, plain SQL with a lightweight query builder and a standalone migration runner keeps dependencies small. You then write and maintain your own result types.
Engineering cost. Prisma onboards developers who are new to SQL fastest. Drizzle onboards SQL-fluent developers fastest. Review effort follows visibility. Drizzle diffs read as SQL. With Prisma, reviewers check both the schema diff and the generated SQL. TypeORM's generated migrations need line-by-line review. Debugging also follows visibility: the more SQL a tool hides, the more you depend on query logging.
Switching mid-project costs more than most teams expect.
Illustrative estimate (assumed numbers, not a benchmark): cost = repositories × hours per repository + migration baseline + fixture rewrite = 40 × 3 h + 16 h + 24 h = 160 engineer-hours
Replace the inputs with your own counts before deciding a rewrite is cheaper than living with the current tool.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to drizzle vs prisma vs typeorm, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.