Drizzle ORM — TypeScript-First Database Toolkit 2026

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Drizzle has become a serious Prisma competitor in 2026. Its zero-runtime-overhead philosophy, SQL-like query API, and 3KB bundle size make it the preferred choice for edge deployments and performance-sensitive applications. Unlike Prisma, Drizzle does not generate a large client and does not require a separate Prisma Engine process. If you want ORM-level convenience with raw SQL-level performance, Drizzle is the answer.

Drizzle vs Prisma at a Glance

FeatureDrizzlePrisma
Bundle size~3KB~900KB
Runtime overheadZeroPrisma Engine process
Schema formatTypeScriptProprietary DSL
Type safetyExcellentGood
SQL transparencyFull controlAbstracted
MigrationsDrizzle KitPrisma Migrate
Edge runtime supportYesLimited
Learning curveModerate (SQL knowledge)Easy

Installation and Setup

# Core Drizzle + PostgreSQL adapter
npm install drizzle-orm postgres
# Or for node-postgres (pg) driver
npm install drizzle-orm pg
npm install --save-dev drizzle-kit @types/pg
// 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,
  max: 20,
  ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false,
});
 
export const db = drizzle(pool, { schema });

Defining the Schema

Drizzle schemas are plain TypeScript — no .prisma files, no code generation needed.

// src/db/schema.ts
import {
  pgTable,
  serial,
  varchar,
  text,
  boolean,
  timestamp,
  integer,
  decimal,
  pgEnum,
  index,
  uniqueIndex,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
export const roleEnum = pgEnum('role', ['user', 'admin', 'moderator']);
 
export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    email: varchar('email', { length: 255 }).notNull(),
    name: varchar('name', { length: 255 }).notNull(),
    role: roleEnum('role').default('user').notNull(),
    isActive: boolean('is_active').default(true).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => ({
    emailIdx: uniqueIndex('users_email_idx').on(table.email),
    roleIdx: index('users_role_idx').on(table.role),
  })
);
 
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 500 }).notNull(),
  body: text('body').notNull(),
  authorId: integer('author_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  published: boolean('published').default(false).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});
 
// Define relations for join queries
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));
 
export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
 
// Infer TypeScript types from schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

Querying with Drizzle

Drizzle's query API closely mirrors SQL syntax, so the mental model is transparent.

// src/db/users.ts
import { db } from './index';
import { users, posts } from './schema';
import { eq, ilike, and, desc, count } from 'drizzle-orm';
 
// SELECT with WHERE
export async function getUserById(id: number) {
  return db.select().from(users).where(eq(users.id, id)).limit(1).then((r) => r[0] ?? null);
}
 
// Relational query — joins handled automatically
export async function getUserWithPosts(id: number) {
  return db.query.users.findFirst({
    where: eq(users.id, id),
    with: {
      posts: {
        where: eq(posts.published, true),
        orderBy: desc(posts.createdAt),
      },
    },
  });
}
 
// Complex WHERE with multiple conditions
export async function searchUsers(email?: string, role?: 'user' | 'admin') {
  const conditions = [];
 
  if (email) conditions.push(ilike(users.email, `%${email}%`));
  if (role) conditions.push(eq(users.role, role));
 
  return db
    .select()
    .from(users)
    .where(conditions.length > 0 ? and(...conditions) : undefined)
    .orderBy(desc(users.createdAt))
    .limit(50);
}
 
// Aggregate query
export async function getUserPostCount(userId: number) {
  const result = await db
    .select({ count: count() })
    .from(posts)
    .where(eq(posts.authorId, userId));
 
  return result[0]?.count ?? 0;
}
 
// INSERT with RETURNING
export async function createUser(data: { email: string; name: string }) {
  const result = await db.insert(users).values(data).returning();
  return result[0];
}
 
// UPDATE
export async function updateUser(id: number, data: Partial<{ name: string; isActive: boolean }>) {
  const result = await db
    .update(users)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(users.id, id))
    .returning();
 
  return result[0] ?? null;
}
 
// DELETE
export async function deleteUser(id: number) {
  await db.delete(users).where(eq(users.id, id));
}

Transactions

// src/db/transactions.ts
import { db } from './index';
import { users, posts } from './schema';
 
export async function createUserWithPost(
  userData: { email: string; name: string },
  postData: { title: string; body: string }
) {
  return db.transaction(async (tx) => {
    // All operations in this callback share a transaction
    const [user] = await tx.insert(users).values(userData).returning();
 
    const [post] = await tx
      .insert(posts)
      .values({ ...postData, authorId: user.id, published: true })
      .returning();
 
    return { user, post };
  });
}

Migrations with Drizzle Kit

# Generate migration files from schema changes
npx drizzle-kit generate
 
# Apply pending migrations
npx drizzle-kit migrate
 
# Push schema directly (dev only)
npx drizzle-kit push
 
# Open Drizzle Studio (database GUI)
npx drizzle-kit studio
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

Common Mistakes

Mistake 1 — Using drizzle-kit push in production: push drops and recreates tables. Always use generated migration files with migrate in production.

Mistake 2 — Forgetting to define relations: without relations(), the db.query.* relational API will not work — you will need to write manual joins.

Mistake 3 — Mutating schema types directly: NewUser is the insert type (without auto-generated fields), User is the select type. Mixing them up causes type errors at runtime.

Mistake 4 — Not indexing foreign key columns: Drizzle creates foreign key constraints but not indexes on those columns — add them explicitly in the table options.

Best Practices

  • Export $inferSelect and $inferInsert types from your schema file so you have consistent types across the codebase.
  • Use Drizzle's relational query API (db.query.*) for joins — it generates optimized SQL with fewer round-trips than manual joins.
  • Keep migrations in version control alongside your application code so deployments and schema changes stay in sync.
  • Use transactions for any write operation that spans multiple tables.
  • Validate incoming data with Zod before passing it to Drizzle insert/update calls — Drizzle does not validate data at runtime.

Key Takeaways

  • Drizzle is a TypeScript-first ORM with a ~3KB footprint — ideal for edge runtimes where Prisma is too heavy.
  • The schema is plain TypeScript, so you get full IDE autocomplete and no proprietary DSL to learn.
  • Drizzle's query API mirrors SQL syntax, giving you full visibility into the queries being generated.
  • The relational query API (db.query.users.findFirst({ with: { posts: true } })) handles joins automatically.
  • Always use drizzle-kit generate + migrate for production schema changes — never push.
  • $inferSelect and $inferInsert provide automatic TypeScript types from your schema without any code generation step.
  • Transactions are first-class in Drizzle via db.transaction(async (tx) => { ... }).
  • Index all foreign key columns manually — Drizzle creates constraints but not indexes on reference columns.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading