If you've worked with ORMs in Node.js, you've probably felt the pain. TypeORM's decorators everywhere. MikroORM's flushing confusion. Prisma's schema language learning curve.
What if I told you there's an ORM that feels like writing SQL, gives you full type safety, and doesn't force you to remember arcane concepts?
What is an ORM?
ORM stands for Object-Relational Mapping. It's a tool that lets you interact with your database using your programming language instead of writing raw SQL.
Instead of this:
SELECT * FROM users WHERE email = 'john@example.com';
You write this:
const user = await db.select().from(users).where(eq(users.email, 'john@example.com'));
ORMs handle the translation between your code and database queries. They manage connections, build queries, and map results back to objects you can use.
What is Drizzle ORM?
Drizzle is a lightweight TypeScript ORM that prioritizes developer experience and performance.
Unlike traditional ORMs that add heavy abstractions, Drizzle stays close to SQL. You define your schema in TypeScript, and Drizzle gives you a type-safe query builder that feels like writing SQL.
Key philosophy: SQL-like syntax with TypeScript types. No magic. No complex abstractions. Just queries that work.
Why Drizzle?
I've built two projects with Drizzle: a booking reservation platform and an AI-powered job search backend. Here's why I chose it:
Speed. Drizzle is lightweight and fast. No heavy runtime overhead.
Type Safety. Full TypeScript inference. Your queries know what they return.
Developer Experience. SQL-like syntax. If you know SQL, you know Drizzle. No new query language to learn.
Simple Migrations. Generate migrations from your schema. No manual SQL writing unless you want to.
Relational Queries. Clean, intuitive joins without the complexity.
Drizzle vs The Rest
| Feature | Drizzle | TypeORM | MikroORM | Knex |
|---|---|---|---|---|
| Learning Curve | Low | Medium | High | Low |
| Type Safety | Excellent | Good | Good | Poor |
| Query Syntax | SQL-like | Builder/Decorators | Repository Pattern | Builder |
| Migrations | Auto-generated | Manual/Auto | Manual/Auto | Manual |
| Runtime Overhead | Minimal | Heavy | Medium | Minimal |
| Mental Model | "Just SQL" | Active Record | Unit of Work | Query Builder |
| Schema Definition | TypeScript | Decorators | Decorators | Manual SQL |
The MikroORM Problem: Remember to flush. Manage entity state. Understand Unit of Work. It's powerful, but complex.
The TypeORM Problem: Decorators everywhere. Query builder can get messy. Migration generation is hit-or-miss.
The Knex Problem: No type safety. You're writing queries blind. Your schema is separate from your code. No auto-generated migrations from schema changes.
The Drizzle Advantage: Write queries that look like SQL. Get types that just work. No flushing. No state management. Schema-driven migrations. Best of both worlds.
Let's Build
We'll build a job search API that filters by title, location, and salary range. This showcases Drizzle's strengths.
Step 1: Installation
npm install drizzle-orm postgres
npm install -D drizzle-kit
Step 2: Database Configuration
Create src/db/schema.ts:
import { pgTable, serial, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core';
export const jobs = pgTable('jobs', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
company: text('company').notNull(),
location: text('location').notNull(),
salaryMin: integer('salary_min'),
salaryMax: integer('salary_max'),
description: text('description').notNull(),
isRemote: boolean('is_remote').default(false),
createdAt: timestamp('created_at').defaultNow(),
});
export const applications = pgTable('applications', {
id: serial('id').primaryKey(),
jobId: integer('job_id').references(() => jobs.id),
applicantName: text('applicant_name').notNull(),
applicantEmail: text('applicant_email').notNull(),
appliedAt: timestamp('applied_at').defaultNow(),
});
Clean. Simple. Just TypeScript objects defining your schema.
Create src/db/db.ts:
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
Step 3: Drizzle Config
Create drizzle.config.ts in your project root:
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;
Step 4: Generate and Run Migrations
# Generate migration from schema
npx drizzle-kit generate:pg
# Run migrations
npx drizzle-kit push:pg
That's it. Drizzle reads your schema and creates the migration. No manual SQL.
Step 5: Create the Drizzle Module
Create src/drizzle/drizzle.module.ts:
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../db/schema';
export const DRIZZLE = Symbol('DRIZZLE');
@Global()
@Module({
providers: [
{
provide: DRIZZLE,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const connectionString = configService.get<string>('DATABASE_URL');
const client = postgres(connectionString);
return drizzle(client, { schema });
},
},
],
exports: [DRIZZLE],
})
export class DrizzleModule {}
The @Global() decorator makes this available everywhere. No need to import it in every module.
Step 6: Create the Jobs Service
Create src/jobs/jobs.service.ts:
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, gte, lte, ilike, SQL } from 'drizzle-orm';
import { DRIZZLE } from '../drizzle/drizzle.module';
import { db } from '../db/db';
import { jobs } from '../db/schema';
@Injectable()
export class JobsService {
constructor(@Inject(DRIZZLE) private db: typeof db) {}
async searchJobs(filters: {
title?: string;
location?: string;
minSalary?: number;
maxSalary?: number;
isRemote?: boolean;
}) {
const conditions: SQL[] = [];
if (filters.title) {
conditions.push(ilike(jobs.title, `%${filters.title}%`));
}
if (filters.location) {
conditions.push(ilike(jobs.location, `%${filters.location}%`));
}
if (filters.minSalary) {
conditions.push(gte(jobs.salaryMin, filters.minSalary));
}
if (filters.maxSalary) {
conditions.push(lte(jobs.salaryMax, filters.maxSalary));
}
if (filters.isRemote !== undefined) {
conditions.push(eq(jobs.isRemote, filters.isRemote));
}
const results = await this.db
.select()
.from(jobs)
.where(conditions.length > 0 ? and(...conditions) : undefined);
return results;
}
async getJobWithApplications(jobId: number) {
const result = await this.db.query.jobs.findFirst({
where: eq(jobs.id, jobId),
with: {
applications: true,
},
});
return result;
}
async createJob(data: {
title: string;
company: string;
location: string;
salaryMin?: number;
salaryMax?: number;
description: string;
isRemote?: boolean;
}) {
const [job] = await this.db.insert(jobs).values(data).returning();
return job;
}
}
Look at that query. It's SQL-like, but type-safe. No query builder hell. No repository patterns. Just clean, readable code.
The relational query with with is chef's kiss. No manual joins. Drizzle handles it.
Step 7: Create the Jobs Controller
Create src/jobs/jobs.controller.ts:
import { Controller, Get, Post, Body, Query, Param, ParseIntPipe } from '@nestjs/common';
import { JobsService } from './jobs.service';
@Controller('jobs')
export class JobsController {
constructor(private readonly jobsService: JobsService) {}
@Get('search')
async search(
@Query('title') title?: string,
@Query('location') location?: string,
@Query('minSalary') minSalary?: string,
@Query('maxSalary') maxSalary?: string,
@Query('isRemote') isRemote?: string,
) {
return this.jobsService.searchJobs({
title,
location,
minSalary: minSalary ? parseInt(minSalary) : undefined,
maxSalary: maxSalary ? parseInt(maxSalary) : undefined,
isRemote: isRemote === 'true' ? true : isRemote === 'false' ? false : undefined,
});
}
@Get(':id')
async getJob(@Param('id', ParseIntPipe) id: number) {
return this.jobsService.getJobWithApplications(id);
}
@Post()
async createJob(@Body() data: any) {
return this.jobsService.createJob(data);
}
}
Step 8: Create the Jobs Module
Create src/jobs/jobs.module.ts:
import { Module } from '@nestjs/common';
import { JobsController } from './jobs.controller';
import { JobsService } from './jobs.service';
@Module({
controllers: [JobsController],
providers: [JobsService],
})
export class JobsModule {}
Step 9: Wire Everything Up
Update src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DrizzleModule } from './drizzle/drizzle.module';
import { JobsModule } from './jobs/jobs.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DrizzleModule,
JobsModule,
],
})
export class AppModule {}
Step 10: Test It
# Search for remote frontend jobs in Manila
GET /jobs/search?title=frontend&location=Manila&isRemote=true
# Search by salary range
GET /jobs/search?minSalary=50000&maxSalary=100000
# Get job with all applications
GET /jobs/1
We just built a fully type-safe job search API with:
- Dynamic filtering
- Relational queries
- Zero boilerplate
- Auto-generated migrations
No flushing. No entity state. No decorator hell. No type-less queries. Just SQL-like syntax with TypeScript types.
Compare this to Knex, where you'd write the same query but with zero type safety:
// Knex - no types, manual column strings
const results = await knex('jobs')
.where('title', 'like', `%${title}%`)
.andWhere('location', 'like', `%${location}%`)
.andWhere('salary_min', '>=', minSalary);
// TypeScript has no idea what columns exist or what types they return
With Drizzle, IntelliSense knows your schema. Typos are caught at compile time. Refactoring is safe.
Advantages
Fast Development. Write schemas. Generate migrations. Query. Done.
Type Safety. IntelliSense knows your database. Catch errors before runtime.
Readable Code. Queries look like SQL. Easy to review. Easy to debug.
Performance. Lightweight. No heavy ORM overhead.
Migrations. Auto-generated from schema changes. No manual SQL unless you want it.
Relational Queries. The with syntax is clean and intuitive.
Disadvantages
Newer Ecosystem. Smaller community than Prisma or TypeORM. Fewer Stack Overflow answers.
Manual Relations. You define relations in code, not in database. More flexibility, but requires understanding.
Less Magic. No auto-save. No change tracking. You write explicit queries. (I consider this an advantage, but some don't.)
Should You Use Drizzle?
Use Drizzle if:
- You know SQL and want to leverage that knowledge
- You want type safety without complexity
- You're building something that needs speed
- You hate remembering ORM-specific concepts
Skip Drizzle if:
- You need a mature ecosystem with tons of plugins
- Your team prefers abstraction over SQL
- You want maximum "magic" with minimal code
Coming from MikroORM, Drizzle felt like freedom. No more flushing. No more entity state management. Just queries.
The learning curve? Almost zero. If you know SQL, you're 90% there.
For my NestJS projects, Drizzle is now my default. Fast development. Clean code. Type-safe queries.