Kim BoenderDrizzle ORM: TypeScript-First SQL Without the Magic
Kim Boender
The ORM space has felt stagnant for a while. TypeORM was the default choice for TypeScript backends for years, but it's aged poorly, decorator-heavy, maintenance concerns, and some genuinely confusing behavior around relations. Prisma swept in and won a lot of hearts with its DX, but it operates at a layer of abstraction that makes you feel like you're not writing SQL at all, which is great until you need to do something slightly unusual and suddenly you're fighting the query engine.
Drizzle ORM takes a different approach. It's TypeScript-first, stays close to SQL, weighs in at ~7.4kb, and has been one of the most talked-about libraries in the JS backend ecosystem over the past year. Here's my take after using it on NestJS projects.
What Makes Drizzle Different
Most ORMs abstract SQL into their own query language. Drizzle does the opposite, it embraces SQL and just makes it type-safe. If you know SQL, you already know most of Drizzle's API. Queries look like queries, not method chains that could mean anything.
The other standout properties:
- Zero dependencies, 7.4kb minified and gzipped, tree-shakeable
- Serverless-first, stateless by design, works great on edge runtimes
- Two query modes, a SQL-like builder and a higher-level relational API
- Drizzle Kit, a companion CLI for schema migrations
- Supports PostgreSQL, MySQL, SQLite, Turso, Neon, PlanetScale, and more
Installation
For a NestJS project with PostgreSQL:
npm install drizzle-orm pg
npm install --save-dev drizzle-kit @types/pgCreate a database connection:
// src/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
export const db = drizzle(pool, { schema })Defining Your Schema
This is where Drizzle's TypeScript-first design really shows. Schema definitions are plain TypeScript objects, no decorators, no class magic:
// src/db/schema.ts
import { pgTable, serial, text, varchar, timestamp, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id')
.notNull()
.references(() => users.id),
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})The schema is your source of truth, Drizzle infers TypeScript types from it automatically. No separate type declarations, no @Column() decorators scattered through class bodies.
Basic Queries
Selects read exactly like SQL:
import { db } from './db'
import { users, posts } from './db/schema'
import { eq, and, gt } from 'drizzle-orm'
// Select all users
const allUsers = await db.select().from(users)
// Select with conditions
const activeUser = await db
.select({
id: users.id,
email: users.email,
name: users.name,
})
.from(users)
.where(eq(users.email, 'kim@example.com'))
.limit(1)
// Insert
const [newUser] = await db
.insert(users)
.values({ email: 'new@example.com', name: 'New User' })
.returning()
// Update
await db
.update(users)
.set({ name: 'Updated Name' })
.where(eq(users.id, 1))
// Delete
await db.delete(posts).where(eq(posts.authorId, 1))Notice there's nothing surprising here. The API mirrors SQL closely enough that you can usually reason through what query Drizzle will produce without checking the docs.
Relations and the Relational Query API
Drizzle has two APIs for working with related data. The first is joins, written explicitly like you would in raw SQL. The second is the relational API, which feels closer to Prisma's include.
Define relations in a separate file:
// src/db/relations.ts
import { defineRelations } from 'drizzle-orm'
import { users, posts } from './schema'
export const relations = defineRelations({ users, posts }, (r) => ({
users: {
posts: r.many(posts, {
from: users.id,
to: posts.authorId,
}),
},
posts: {
author: r.one(users, {
from: posts.authorId,
to: users.id,
}),
},
}))Then query with the relational API:
// Fetch all users with their posts
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
})
// Fetch a post with its author
const post = await db.query.posts.findFirst({
where: eq(posts.id, 42),
with: {
author: {
columns: { email: true, name: true },
},
},
})The relational API is cleaner for typical CRUD and association fetching. The SQL builder is better when you need aggregates, complex joins, or anything that doesn't fit a standard findMany/findOne pattern.
Having both available is genuinely useful rather than feeling like an afterthought.
Migrations with Drizzle Kit
Drizzle Kit handles schema migrations as a companion CLI. Add a config file:
// drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies ConfigThen generate migrations from your schema changes:
# Generate migration SQL from schema diff
npx drizzle-kit generate
# Apply migrations
npx drizzle-kit migrate
# Open Drizzle Studio, a browser UI to explore your data
npx drizzle-kit studioThe migrations are plain SQL files that get generated into the drizzle/ folder. You can review and commit them alongside your code. No magic runtime migration discovery, just files you control.
Drizzle Studio is worth calling out separately, it's a clean browser-based database explorer that runs locally via the CLI. Not a must-have, but nice to have during development.
NestJS Integration
Drizzle doesn't have an official NestJS module, but it's straightforward to wire up as a provider:
// src/db/db.module.ts
import { Module, Global } from '@nestjs/common'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema'
export const DB = Symbol('DB')
@Global()
@Module({
providers: [
{
provide: DB,
useFactory: () => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
return drizzle(pool, { schema })
},
},
],
exports: [DB],
})
export class DbModule {}Inject it in any service:
import { Inject, Injectable } from '@nestjs/common'
import { DB } from '../db/db.module'
import { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '../db/schema'
import { eq } from 'drizzle-orm'
@Injectable()
export class UsersService {
constructor(
@Inject(DB) private readonly db: NodePgDatabase<typeof schema>,
) {}
async findById(id: number) {
return this.db.query.users.findFirst({
where: eq(schema.users.id, id),
})
}
}The lack of an official NestJS module is a minor friction point, there are community packages like nestjs-drizzle that wrap this up. In practice I've found the manual wiring above clean enough that I don't bother with extra packages.
How It Compares
If you're coming from Prisma, the adjustment is primarily around schema definition (TypeScript vs .prisma files) and the fact that Drizzle doesn't generate a client from a schema at build time. Some people love Prisma's generated client; I find Drizzle's approach more predictable.
If you're coming from TypeORM, Drizzle is a substantial improvement in almost every dimension, no decorator hell, no confusing lazy/eager loading defaults, and significantly better performance.
The one real gap compared to Prisma is ecosystem maturity. Prisma has more guides, more third-party integrations, and a larger community. Drizzle is catching up fast, but if you're on a team that relies heavily on community resources, it's worth factoring in.
Is It Worth Adopting?
For new NestJS + PostgreSQL projects, I'd reach for Drizzle first today. The type safety is excellent, the bundle size is genuinely impressive for serverless and edge workloads, and writing SQL-like queries means the code is self-explanatory without needing to trace through abstraction layers.
For serverless deployments, on Neon, PlanetScale, or similar serverless Postgres platforms, Drizzle's stateless connection model is a particular advantage over Prisma's persistent connection assumptions.
If you're on an existing Prisma project that's working fine, there's no urgent reason to migrate. But if Prisma's abstraction is causing you friction on complex queries, Drizzle is worth evaluating seriously. The migration path is less painful than it might look.