Context & Why Drizzle ORM?
If you’ve spent any time with TypeScript backends, you’ve probably hit the same wall I did: your database layer and your TypeScript types live in two separate worlds. Write an interface, write a Prisma model, write a Zod schema — and when the database changes, you’re chasing down three places to update. Exhausting doesn’t cover it.
Drizzle ORM sidesteps this entirely. No separate schema language, no heavy runtime — your schema is your TypeScript. Define a table and you get the types for free. No code generation step, no mismatch between what the DB knows and what TypeScript knows.
I’ve been running this in production on a SaaS project handling around 150k daily queries. Six months in: zero runtime type surprises, predictable migration output, and queries that feel like writing TypeScript rather than SQL strings wrapped in template literals.
For teams that need SQL visibility without giving up type safety, Drizzle occupies a space nothing else quite fills. Drop to raw SQL whenever you need to — the ORM won’t fight you. That flexibility is genuinely rare.
Here’s what makes it stand out:
- Schema-first with TypeScript — your table definitions are just TypeScript objects
- drizzle-kit — a CLI tool that generates and runs SQL migrations from your schema diff
- Relational query API — declare relations once, then query nested data without writing JOINs manually
- Thin runtime — no magic, no hidden queries, predictable performance
Installation
Start with a fresh TypeScript project, or drop into an existing one. You need Node.js 18+ and a running PostgreSQL instance.
Install dependencies
npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg tsx typescript
drizzle-orm— the main librarypg— PostgreSQL driver (Drizzle supports multiple drivers)drizzle-kit— the migration CLItsx— lets you run TypeScript files directly during development
Initialize TypeScript
npx tsc --init
Make sure your tsconfig.json has at least these settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"strict": true,
"outDir": "./dist"
}
}
Configuration
Step 1 — Define your database connection
Create src/db/index.ts to hold the connection and export the db instance:
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 });
Store your connection string in a .env file:
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
Step 2 — Write your schema
Open (or create) src/db/schema.ts. Table definitions are plain TypeScript — no decorators, no special syntax:
import { pgTable, serial, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
body: text('body').notNull(),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow(),
});
Relations come next. Declare them so Drizzle knows how to join tables without you spelling it out every time:
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
No string magic, no manual type annotations. TypeScript infers the full shape from column definitions alone.
Step 3 — Configure drizzle-kit
Add a drizzle.config.ts at the project root:
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config;
Wire up the scripts in package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push"
}
}
Step 4 — Generate and run migrations
Once your schema is ready, generate the SQL migration:
npm run db:generate
Drizzle diffs your schema against the last known state and writes a SQL file under ./drizzle/. Open it. You’ll see clean, readable SQL — no abstractions hiding what’s actually happening.
Then apply it:
npm run db:migrate
During development, db:push skips the migration file step and applies changes directly to the database. Useful for fast iteration when you don’t need a migration history yet.
Step 5 — Write type-safe queries
With the schema wired up, every query carries full TypeScript inference. Create src/index.ts:
import 'dotenv/config';
import { db } from './db';
import { users, posts } from './db/schema';
import { eq } from 'drizzle-orm';
// Insert a user
const [newUser] = await db.insert(users).values({
name: 'Alice',
email: '[email protected]',
}).returning();
console.log(newUser.id); // TypeScript knows this is a number
// Insert a post linked to that user
await db.insert(posts).values({
title: 'My First Post',
body: 'Hello from Drizzle ORM',
authorId: newUser.id,
});
// Query with relation (no manual JOIN needed)
const result = await db.query.users.findFirst({
where: eq(users.id, newUser.id),
with: {
posts: true,
},
});
console.log(result?.posts); // TypeScript infers the full shape
Run it:
npx tsx src/index.ts
The with API in db.query.* uses the relations you declared earlier. Under the hood, Drizzle generates an efficient JOIN — you get nested objects back instead of flat rows.
Step 6 — Filter and select specific columns
Fetching every column is rarely what you want. Drizzle’s select API makes partial selects type-safe too:
const titles = await db
.select({ title: posts.title, author: users.name })
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(users.name, 'Alice'));
// titles is typed as { title: string; author: string }[]
No casting, no as any. The return type narrows automatically to exactly what you selected.
Verification & Monitoring
Drizzle Studio — visual DB explorer
Drizzle ships a browser-based UI you can spin up locally:
npm run db:studio
Open https://local.drizzle.studio. Browse rows, inspect tables, run queries manually — handy during development to confirm that migrations landed correctly.
Check migration history
Applied migrations are tracked in a __drizzle_migrations table. Query it to see what’s been applied:
psql $DATABASE_URL -c "SELECT * FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 10;"
Each file in ./drizzle/ gets a matching entry once it runs. Missing from the table? It hasn’t been applied yet.
Log SQL queries during development
Pass a logger to drizzle() to see the actual SQL being executed:
export const db = drizzle(pool, {
schema,
logger: true, // prints all SQL to stdout
});
Turn it off in production. This is the fastest way to catch N+1 issues early — if you see the same query repeating in a loop, you need to push it outside or batch it.
Handling schema changes safely
Update a column type or add a constraint? Run db:generate again. Drizzle diffs against the previous snapshot and writes an incremental migration file.
Read the generated SQL before running db:migrate — especially for column drops or type changes that could cause data loss:
# Preview what changed before applying
npm run db:generate
cat drizzle/0001_*.sql # read the generated SQL
npm run db:migrate # apply after review
For production deploys, run migrations in your CI/CD pipeline before starting the application — not on application startup. This keeps deploys atomic and avoids race conditions when scaling horizontally.
Quick sanity check script
A simple health check to confirm the DB connection and schema are in sync:
// src/db/health.ts
import { db } from '.';
import { sql } from 'drizzle-orm';
export async function checkDb() {
const result = await db.execute(sql`SELECT 1 AS ok`);
console.log('DB connected:', result.rows[0]);
}
Run it after each deploy to confirm connectivity before serving traffic.
Where to Go Next
Once this setup is running, the obvious next moves are adding transactions (db.transaction()), tuning your connection pool size to match your actual load, and exploring Drizzle’s prepared statements for high-frequency queries. The official docs cover all of these thoroughly.
What struck me most after going through this setup: the gap between the database and TypeScript gets genuinely small. Change a column in the schema and TypeScript immediately flags every query that relies on it — before you touch the database. That alone has caught several bugs that would have only surfaced in production with a more traditional setup.

