Introduction
Drizzle ORM is a lightweight, high-performance ORM for TypeScript, designed for developers who want full control over their SQL queries without the overhead of traditional ORMs like Prisma or TypeORM. In 2026, it's the go-to choice for scalable Node.js apps thanks to its type-safe compilation, automated migrations, and native support for databases like PostgreSQL, MySQL, or SQLite.
Why choose it? Imagine an ORM that generates optimized raw SQL, infers types from your database in real time, and skips the magical abstractions that hide performance issues. This intermediate tutorial walks you through building a complete API with users and posts, including relations, transactions, and validations. By the end, you'll master Drizzle to speed up your backends by 30-50% on query performance. Ready to dive into the code?
Prerequisites
- Node.js 20+ and npm/yarn/pnpm
- Knowledge of TypeScript and basic SQL
- Editor like VS Code with the Drizzle extension
- SQLite for simplicity (no external DB setup required)
Installing Dependencies
mkdir drizzle-tutorial
cd drizzle-tutorial
npm init -y
npm install drizzle-orm better-sqlite3
dnpm install -D drizzle-kit typescript @types/node tsx
npm install -D @types/better-sqlite3This command initializes a Node.js project and installs Drizzle ORM with the SQLite driver (better-sqlite3 for optimal performance). Drizzle-kit handles migrations, TypeScript ensures type safety, and tsx lets you run TS files directly. Skip pg for PostgreSQL here to keep it simple; run npm install again if any peer deps are missing.
Initial TypeScript Setup
Create a tsconfig.json file for strict configuration. This enables static error checking and Drizzle's type inference, crucial for avoiding runtime bugs. Drizzle leverages TypeScript generics to perfectly map schemas and queries.
TypeScript Configuration
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
}
}This tsconfig is optimized for Drizzle: strict: true enforces type safety, and moduleResolution: bundler supports modern imports. Copy-paste it as-is; it catches 90% of SQL typing errors. Pitfall: without composite: true, incremental builds fail.
Defining the Database Schema
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull().unique(),
});
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
content: text('content'),
userId: integer('user_id').references(() => users.id),
}, (table) =>({
userIdx: uniqueIndex('user_idx').on(table.userId),
}));This schema defines two tables: users with basic fields and posts with a foreign key relation to users. Drizzle automatically infers TypeScript types (e.g., SelectUsers). The unique index on userId optimizes joins. Pitfall: skip references(() => users.id) and relational types break.
Generating Migrations
Drizzle-kit analyzes your TypeScript schema to generate idempotent SQL migrations. It's like 'git for your DB': version them in migrations/ for CI/CD deployments.
Drizzle Kit Configuration
import type { Config } from 'drizzle-kit';
export default {
schema: './src/schema.ts',
out: './migrations',
dialect: 'sqlite',
dbCredentials: {
url: './sqlite.db',
},
} satisfies Config;This config points to your schema and outputs SQL migrations. dialect: 'sqlite' matches the driver; the url points to the DB file. Then run npx drizzle-kit generate:sqlite. Pitfall: wrong relative path = empty migrations.
Database Connection and Migration
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
const sqlite = new Database('./sqlite.db');
export const db = drizzle(sqlite, { schema });
// Migration manuelle (ou via CLI)
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
await migrate(db, { migrationsFolder: './migrations' });This sets up a lightweight SQLite connection pool and attaches the schema for type inference. migrate() applies migrations automatically. Use async/await in your app init. Pitfall: without schema, no TypeScript autocomplete on queries.
Basic CRUD Operations
Like a SQL master chef: Drizzle compiles your TypeScript queries into optimized SQL. Generics ensure zero runtime errors.
Complete CRUD Example
import { db } from './db';
import { users, posts } from './schema';
import { eq, desc } from 'drizzle-orm';
// CREATE
await db.insert(users).values({ name: 'Alice', email: 'alice@example.com' });
// READ avec jointure
const results = await db
.select({
postId: posts.id,
title: posts.title,
userName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.userId, users.id))
.orderBy(desc(posts.id));
// UPDATE
await db.update(users).set({ name: 'Alice Updated' }).where(eq(users.id, 1));
// DELETE
await db.delete(users).where(eq(users.id, 1));This block shows insert, select with join, update, and delete. eq() and desc() are type-safe helpers for WHERE/ORDER. Copy into a script and run tsx src/crud.ts after migrating. Pitfall: without eq(), dynamic clauses lose type safety.
Transactions and Advanced Relations
import { db } from './db';
import { users, posts } from './schema';
import { eq, and } from 'drizzle-orm';
await db.transaction(async (tx) => {
const newUserId = await tx.insert(users).values({ name: 'Bob', email: 'bob@example.com' }).returning({ id: users.id });
await tx.insert(posts).values([
{ title: 'Premier post', content: 'Contenu', userId: newUserId[0].id },
{ title: 'Deuxième post', userId: newUserId[0].id }
]);
});
// Query relationnelle
const userPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: true,
},
});ACID transactions protect multi-step ops; returning() gets the generated ID. db.query simplifies one-to-many relations. Perfect for user/post APIs. Pitfall: forget tx in transaction = separate commits and data inconsistency.
Integrating with a Next.js API
For a real API, wrap the db in a singleton. Example GET /users route.
API Route with Drizzle
import { db } from '@/db';
import { users } from '@/db/schema';
import { NextResponse } from 'next/server';
export async function GET() {
const allUsers = await db.select().from(users);
return NextResponse.json(allUsers);
}
export async function POST(request: Request) {
const body = await request.json();
const newUser = await db.insert(users).values(body).returning();
return NextResponse.json(newUser, { status: 201 });
}Next.js 14+ App Router route using Drizzle for GET/POST. Add Zod for body type safety. Test with curl POST. Pitfall: without returning(), no response data; add validation for production.
Best Practices
- Always use transactions for multi-table ops: guarantees atomicity.
- Validate inputs with Zod and
prepared()for SQL injection (though Drizzle prepares natively). - Paginate queries:
limit(10).offset(page * 10)for scalability. - Index relations: add
index()in schema for fast joins. - Use env vars for DB URL:
process.env.DATABASE_URL.
Common Errors to Avoid
- No migrate on startup: empty DB = crashes on selects; wrap in try/catch.
- Wrong relative imports:
schema.tsmust be accessible everywhere or types are lost. - Forget references(): broken relations, no query autocomplete.
- Run without tsx: TS errors ignored at runtime; always use
tsx src/script.ts.
Next Steps
Go further with PostgreSQL for production: swap better-sqlite3 for @neondatabase/serverless. Check the official Drizzle docs. Explore our Learni trainings on advanced databases to master CI/CD migrations and sharding.