Building a production-ready REST API requires a database layer that is fast, type-safe, and easy to maintain. In 2026, Drizzle ORM has emerged as the go-to choice for TypeScript developers who want the power of SQL without sacrificing type safety or performance. With v0.45.1 stable and v1.0.0 beta actively in development, Drizzle is maturing fast.
This guide walks you through building a complete, type-safe REST API with Drizzle ORM, Node.js, and PostgreSQL — covering schema definition, migrations, relations, and real-world query patterns.
Why Drizzle ORM in 2026?
Before we build, let's understand why Drizzle has overtaken Prisma and TypeORM as the preferred ORM for Node.js APIs:
- ~7.4kb minified+gzipped — zero heavy runtime footprint
- Zero dependencies — nothing to break
- SQL-first — write TypeScript that feels like SQL, not magic
- Full TypeScript inference — your schema IS your types
- Works everywhere — PostgreSQL, MySQL, SQLite, serverless (Neon, Turso, Cloudflare D1)
-
Drizzle Studio — built-in browser-based DB GUI (
npx drizzle-kit studio)
Unlike Prisma which generates a massive client and hides SQL behind abstractions, Drizzle keeps you in control while still giving you complete compile-time type safety.
Prerequisites
You'll need:
- Node.js 22+ (or Bun 1.x)
- PostgreSQL 16+ running locally or a connection string (Neon, Supabase, etc.)
- TypeScript knowledge
Step 1: Initialize the Project
mkdir drizzle-api && cd drizzle-api
npm init -y
npm install drizzle-orm postgres
npm install -D drizzle-kit typescript tsx @types/node
Initialize TypeScript:
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict true --outDir dist
Your package.json scripts:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}
Step 2: Define Your Drizzle Schema
Drizzle schemas are just TypeScript — no separate .prisma files. Create src/db/schema.ts:
import {
pgTable,
serial,
varchar,
text,
integer,
timestamp,
boolean,
index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Users table
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [
index('users_email_idx').on(table.email),
index('users_created_at_idx').on(table.createdAt),
]
);
// Posts table
export const posts = pgTable(
'posts',
{
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
content: text('content').notNull(),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
published: boolean('published').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => [
index('posts_author_idx').on(table.authorId),
index('posts_published_idx').on(table.published),
]
);
// Comments table
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
content: text('content').notNull(),
postId: integer('post_id')
.notNull()
.references(() => posts.id, { onDelete: 'cascade' }),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
Key insight: Notice how indexes are defined inline with the schema. Drizzle's
pgTableaccepts a second argument for constraints and indexes — keeping everything in one place.
Step 3: Define Relations
Relations in Drizzle are separate from the schema but crucial for typed relational queries. Add to schema.ts:
// Relations (for Drizzle relational queries)
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
}));
Step 4: Database Client Setup
Create src/db/client.ts:
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString = process.env.DATABASE_URL!;
// For migrations: max 1 connection
const migrationClient = postgres(connectionString, { max: 1 });
// For queries: connection pool (default 10)
const queryClient = postgres(connectionString);
export const db = drizzle(queryClient, { schema });
// Export migration client separately
export const migrationDb = drizzle(migrationClient);
And the Drizzle config drizzle.config.ts at the root:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
Step 5: Generate and Run Migrations
# Generate SQL migration files from your schema
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb npm run db:generate
# Apply migrations to the database
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb npm run db:migrate
Drizzle Kit creates a drizzle/ folder with timestamped SQL files:
-- drizzle/0000_initial.sql (generated automatically)
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(100) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
When you change your schema (add a column, rename a table), just run db:generate again — Drizzle detects the diff and generates a new migration file.
Step 6: Build the REST API Routes
Create src/routes/users.ts:
import { Router, Request, Response } from 'express';
import { db } from '../db/client.js';
import { users, posts } from '../db/schema.js';
import { eq, desc, count } from 'drizzle-orm';
const router = Router();
// GET /users — list all users with post count
router.get('/', async (req: Request, res: Response) => {
try {
const result = await db
.select({
id: users.id,
email: users.email,
name: users.name,
isActive: users.isActive,
createdAt: users.createdAt,
postCount: count(posts.id),
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.groupBy(users.id)
.orderBy(desc(users.createdAt));
res.json({ data: result, total: result.length });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// GET /users/:id — single user with their posts (relational query)
router.get('/:id', async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
const result = await db.query.users.findFirst({
where: eq(users.id, id),
with: {
posts: {
where: eq(posts.published, true),
orderBy: [desc(posts.createdAt)],
limit: 10,
},
},
});
if (!result) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: result });
});
// POST /users — create a user
router.post('/', async (req: Request, res: Response) => {
const { email, name } = req.body;
try {
const [newUser] = await db
.insert(users)
.values({ email, name })
.returning();
res.status(201).json({ data: newUser });
} catch (err: any) {
if (err.code === '23505') {
// PostgreSQL unique violation
return res.status(409).json({ error: 'Email already exists' });
}
res.status(500).json({ error: 'Failed to create user' });
}
});
// PATCH /users/:id — update a user
router.patch('/:id', async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
const { name, isActive } = req.body;
const [updated] = await db
.update(users)
.set({
...(name && { name }),
...(isActive !== undefined && { isActive }),
updatedAt: new Date(),
})
.where(eq(users.id, id))
.returning();
if (!updated) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: updated });
});
// DELETE /users/:id — soft delete (set isActive = false)
router.delete('/:id', async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
await db
.update(users)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(users.id, id));
res.status(204).send();
});
export default router;
Step 7: Advanced Query Patterns
Pagination with Cursor-Based Approach
import { gt, and } from 'drizzle-orm';
// Cursor-based pagination (better than OFFSET for large datasets)
async function getPaginatedPosts(cursor?: number, limit = 20) {
return db
.select()
.from(posts)
.where(
and(
eq(posts.published, true),
cursor ? gt(posts.id, cursor) : undefined
)
)
.orderBy(posts.id)
.limit(limit);
}
Transactions
async function createPostWithNotification(
userId: number,
postData: { title: string; content: string }
) {
return db.transaction(async (tx) => {
const [newPost] = await tx
.insert(posts)
.values({ ...postData, authorId: userId, published: true })
.returning();
await tx
.update(users)
.set({ updatedAt: new Date() })
.where(eq(users.id, userId));
return newPost;
});
}
Batch Queries
// Execute multiple queries in a single round trip
const [allUsers, publishedPosts, totalComments] = await db.batch([
db.select().from(users).where(eq(users.isActive, true)),
db.select().from(posts).where(eq(posts.published, true)),
db.select({ count: count() }).from(comments),
]);
Step 8: Wire Up the Express Server
Create src/index.ts:
import express from 'express';
import usersRouter from './routes/users.js';
const app = express();
app.use(express.json());
app.use('/users', usersRouter);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`API running at http://localhost:${PORT}`);
});
Step 9: Using Drizzle Studio for Debugging
One of Drizzle's killer features is the built-in database GUI:
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb npm run db:studio
This opens a browser-based interface at https://local.drizzle.studio where you can browse tables, run queries, and inspect your data without leaving your terminal workflow.
Performance Tips for Production
1. Use connection pooling appropriately:
const queryClient = postgres(connectionString, {
max: 20, // max connections
idle_timeout: 30, // seconds
connect_timeout: 10,
});
2. Avoid N+1 queries — use with for relational data:
// ❌ N+1 problem
const allUsers = await db.select().from(users);
for (const user of allUsers) {
user.posts = await db.select().from(posts).where(eq(posts.authorId, user.id));
}
// ✅ Single query with Drizzle relations
const allUsers = await db.query.users.findMany({
with: { posts: true },
});
3. Select only what you need:
// ❌ Fetches all columns
const result = await db.select().from(users);
// ✅ Only the columns you actually use
const result = await db.select({
id: users.id,
name: users.name,
}).from(users);
Connecting to External APIs
Once your Drizzle-powered API is live, you can extend it with external data sources. 1xAPI provides ready-to-use APIs for sports data, live scores, and streaming metadata that integrate cleanly with a Drizzle-backed Node.js API. The type-safe approach means you can map external API responses directly to your Drizzle schema with confidence.
Summary
In this guide you've built:
- ✅ A typed PostgreSQL schema with indexes and foreign keys
- ✅ Drizzle relations for efficient relational queries
- ✅ Auto-generated SQL migrations with
drizzle-kit - ✅ Full CRUD routes with
returning()for immediate feedback - ✅ Transactions, batch queries, and cursor pagination
- ✅ Production connection pooling tips
Drizzle ORM (v0.45.1 stable, v1.0.0 beta as of March 2026) hits the sweet spot between raw SQL control and full type safety. For any Node.js API built in 2026, it's hard to justify the overhead of Prisma or the looseness of Sequelize when Drizzle gives you this much for ~7.4kb.
Top comments (0)