Skip to main content
This guide covers battle-tested patterns and best practices for building robust, performant applications with Drizzle ORM.

Schema Design

Naming Conventions

Establish consistent naming patterns across your schema:
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';

// Good: Singular table names, clear column names
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  firstName: text('first_name'),
  lastName: text('last_name'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

// Avoid: Inconsistent naming
export const Users = pgTable('user', { // Mixed naming
  ID: serial('user_id').primaryKey(), // Inconsistent ID column
  EmailAddress: text('email'), // CamelCase in DB
});
Table names: Use plural, snake_case (users, blog_posts)
Column names: Use snake_case in database, camelCase in TypeScript
Variable names: Use camelCase for table references (users, blogPosts)

Use Specific Column Types

Choose the most appropriate column type for your data:
import {
  pgTable,
  serial,
  text,
  varchar,
  integer,
  boolean,
  timestamp,
  jsonb,
  decimal,
} from 'drizzle-orm/pg-core';

export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  
  // Good: Specific length for known limits
  sku: varchar('sku', { length: 50 }).notNull().unique(),
  
  // Good: text for unlimited content
  description: text('description'),
  
  // Good: Appropriate numeric types
  price: decimal('price', { precision: 10, scale: 2 }).notNull(),
  stock: integer('stock').notNull().default(0),
  
  // Good: Boolean for true/false states
  isActive: boolean('is_active').notNull().default(true),
  
  // Good: JSONB for structured data
  metadata: jsonb('metadata').$type<{
    color?: string;
    size?: string;
    tags?: string[];
  }>(),
  
  // Good: Timestamp with timezone
  createdAt: timestamp('created_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
});

Foreign Keys and Indexes

Always add indexes to foreign keys and frequently queried columns:
import { pgTable, serial, integer, text, index } from 'drizzle-orm/pg-core';
import { users } from './users';

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  authorId: integer('author_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  status: text('status').notNull().default('draft'),
}, (table) => ({
  // Index foreign keys
  authorIdIdx: index('author_id_idx').on(table.authorId),
  
  // Index frequently filtered columns
  statusIdx: index('status_idx').on(table.status),
  
  // Composite index for common query patterns
  authorStatusIdx: index('author_status_idx').on(
    table.authorId,
    table.status
  ),
}));
PlanetScale: Doesn’t support foreign key constraints. Remove .references() and manage referential integrity in application code.

Timestamps and Soft Deletes

Implement standard timestamp patterns:
import { pgTable, serial, text, timestamp, boolean } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull(),
  
  // Creation timestamp
  createdAt: timestamp('created_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
  
  // Auto-updating timestamp
  updatedAt: timestamp('updated_at', { withTimezone: true })
    .notNull()
    .defaultNow()
    .$onUpdate(() => new Date()),
  
  // Soft delete
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
});

// Helper function to filter out deleted records
import { isNull } from 'drizzle-orm';

export function withoutDeleted() {
  return isNull(users.deletedAt);
}

// Usage
const activeUsers = await db.select()
  .from(users)
  .where(withoutDeleted());

Type-Safe JSON Columns

Define TypeScript types for JSON columns:
import { pgTable, serial, jsonb } from 'drizzle-orm/pg-core';

// Define your JSON structure
interface UserPreferences {
  theme: 'light' | 'dark';
  notifications: {
    email: boolean;
    push: boolean;
  };
  language: string;
}

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  
  // Type-safe JSON column
  preferences: jsonb('preferences')
    .$type<UserPreferences>()
    .notNull()
    .default({
      theme: 'light',
      notifications: { email: true, push: false },
      language: 'en',
    }),
});

// TypeScript knows the structure
const user = await db.select().from(users).limit(1);
const theme = user[0].preferences.theme; // Type: 'light' | 'dark'

Query Optimization

Use Prepared Statements

Prepared statements improve performance for repeated queries:
import { eq } from 'drizzle-orm';
import { users } from './schema';

// Create prepared statement once
const getUserById = db.select()
  .from(users)
  .where(eq(users.id, sql.placeholder('id')))
  .prepare('get_user_by_id');

// Execute multiple times efficiently
const user1 = await getUserById.execute({ id: 1 });
const user2 = await getUserById.execute({ id: 2 });
const user3 = await getUserById.execute({ id: 3 });

Select Only Needed Columns

Avoid selecting entire rows when you only need specific fields:
import { users, posts } from './schema';

// Bad: Fetches all columns
const allUsers = await db.select().from(users);

// Good: Select only what you need
const userEmails = await db.select({
  id: users.id,
  email: users.email,
}).from(users);

// Good: Computed columns
const userStats = await db.select({
  userId: users.id,
  userName: users.name,
  postCount: count(posts.id),
})
  .from(users)
  .leftJoin(posts, eq(users.id, posts.authorId))
  .groupBy(users.id);

Batch Operations

Use batch inserts and queries to reduce round trips:
import { users } from './schema';

// Bad: Multiple individual inserts
for (const user of newUsers) {
  await db.insert(users).values(user);
}

// Good: Single batch insert
await db.insert(users).values(newUsers);

// Good: Batch queries (where supported)
const [usersResult, postsResult, commentsResult] = await db.batch([
  db.select().from(users),
  db.select().from(posts),
  db.select().from(comments),
]);

Pagination Patterns

Implement efficient pagination:
import { desc, gt } from 'drizzle-orm';
import { posts } from './schema';

// Offset-based pagination (simple but slow for large offsets)
export async function getPostsOffset(page: number, pageSize: number) {
  return await db.select()
    .from(posts)
    .orderBy(desc(posts.createdAt))
    .limit(pageSize)
    .offset(page * pageSize);
}

// Cursor-based pagination (recommended for large datasets)
export async function getPostsCursor(
  cursor: number | null,
  pageSize: number
) {
  const query = db.select()
    .from(posts)
    .orderBy(desc(posts.id))
    .limit(pageSize + 1); // Fetch one extra to check if there's more
  
  if (cursor) {
    query.where(gt(posts.id, cursor));
  }
  
  const results = await query;
  const hasMore = results.length > pageSize;
  const items = hasMore ? results.slice(0, -1) : results;
  
  return {
    items,
    nextCursor: hasMore ? items[items.length - 1].id : null,
  };
}

Efficient Joins

Use the right join type and avoid n+1 queries:
import { eq } from 'drizzle-orm';
import { users, posts, comments } from './schema';

// Bad: N+1 query problem
const users = await db.select().from(usersTable);
for (const user of users) {
  user.posts = await db.select()
    .from(posts)
    .where(eq(posts.authorId, user.id));
}

// Good: Use relational queries
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: true,
  },
});

// Good: Manual join when you need control
const usersWithPostCount = await db.select({
  id: users.id,
  name: users.name,
  postCount: count(posts.id),
})
  .from(users)
  .leftJoin(posts, eq(users.id, posts.authorId))
  .groupBy(users.id);

Type Safety

Infer Types from Schema

Always use type inference instead of manual types:
import { users } from './schema';

// Good: Infer from schema
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;

// Use in functions
export async function createUser(data: NewUser): Promise<User> {
  const [user] = await db.insert(users).values(data).returning();
  return user;
}

// For partial updates
type UserUpdate = Partial<NewUser>;

export async function updateUser(
  id: number,
  data: UserUpdate
): Promise<User> {
  const [user] = await db.update(users)
    .set(data)
    .where(eq(users.id, id))
    .returning();
  return user;
}

Custom Type Helpers

Create reusable type utilities:
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';

// Generic helpers
export type SelectModel<T extends typeof users> = InferSelectModel<T>;
export type InsertModel<T extends typeof users> = InferInsertModel<T>;

// Make all fields optional for updates
export type UpdateModel<T> = Partial<InsertModel<T>>;

// Pick specific fields
export type UserPublic = Pick<SelectModel<typeof users>, 'id' | 'name' | 'email'>;

// Omit sensitive fields
export type UserSafe = Omit<SelectModel<typeof users>, 'passwordHash'>;

Validated Inputs with Zod

Combine Drizzle with Zod for runtime validation:
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
import { users } from './schema';

// Generate Zod schemas from Drizzle tables
const insertUserSchema = createInsertSchema(users, {
  // Customize validation
  email: z.string().email(),
  name: z.string().min(2).max(100),
});

const selectUserSchema = createSelectSchema(users);

// Use for API validation
export async function createUserApi(input: unknown) {
  // Validate input
  const validated = insertUserSchema.parse(input);
  
  // Insert with validated data
  const [user] = await db.insert(users)
    .values(validated)
    .returning();
  
  return user;
}

Connection Management

Pool Configuration

Configure connection pools appropriately:
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';

// Good: Proper pool configuration
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // Maximum connections
  idleTimeoutMillis: 30000, // Close idle connections after 30s
  connectionTimeoutMillis: 2000, // Fail fast
});

const db = drizzle(pool);

// Handle pool errors
pool.on('error', (err) => {
  console.error('Unexpected pool error', err);
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  await pool.end();
  process.exit(0);
});

Singleton Pattern

Avoid creating multiple database instances:
db.ts
import { drizzle } from 'drizzle-orm/neon-http';
import type { NeonHttpDatabase } from 'drizzle-orm/neon-http';
import * as schema from './schema';

// Singleton pattern
let db: NeonHttpDatabase<typeof schema> | null = null;

export function getDb() {
  if (!db) {
    db = drizzle({
      connection: process.env.DATABASE_URL!,
      schema,
    });
  }
  return db;
}

// Usage
import { getDb } from './db';

const db = getDb();
const users = await db.select().from(usersTable);

Environment-Specific Configuration

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

const isProduction = process.env.NODE_ENV === 'production';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  
  // Production settings
  ssl: isProduction ? { rejectUnauthorized: false } : undefined,
  max: isProduction ? 20 : 5,
  
  // Development settings
  idleTimeoutMillis: isProduction ? 30000 : 10000,
});

const db = drizzle(pool, {
  logger: !isProduction, // Log queries in development
});

export { db };

Error Handling

Graceful Error Recovery

import { DatabaseError } from 'pg';
import { users } from './schema';

export async function createUser(email: string, name: string) {
  try {
    const [user] = await db.insert(users)
      .values({ email, name })
      .returning();
    return { success: true, data: user };
  } catch (error) {
    // Handle unique constraint violation
    if (error instanceof DatabaseError && error.code === '23505') {
      return { 
        success: false, 
        error: 'Email already exists' 
      };
    }
    
    // Handle foreign key violation
    if (error instanceof DatabaseError && error.code === '23503') {
      return { 
        success: false, 
        error: 'Referenced record does not exist' 
      };
    }
    
    // Log unexpected errors
    console.error('Database error:', error);
    return { 
      success: false, 
      error: 'An unexpected error occurred' 
    };
  }
}

Transaction Error Handling

import { users, posts } from './schema';

export async function createUserWithPost(
  userData: InsertModel<typeof users>,
  postData: Omit<InsertModel<typeof posts>, 'authorId'>
) {
  try {
    return await db.transaction(async (tx) => {
      // Create user
      const [user] = await tx.insert(users)
        .values(userData)
        .returning();
      
      // Create post
      const [post] = await tx.insert(posts)
        .values({ ...postData, authorId: user.id })
        .returning();
      
      return { user, post };
    });
  } catch (error) {
    // Transaction automatically rolled back
    console.error('Transaction failed:', error);
    throw error;
  }
}

Testing

Test Database Setup

test-utils.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import * as schema from './schema';

let testDb: ReturnType<typeof drizzle>;
let testPool: Pool;

export async function setupTestDb() {
  testPool = new Pool({
    connectionString: process.env.TEST_DATABASE_URL,
  });
  
  testDb = drizzle(testPool, { schema });
  
  // Run migrations
  await migrate(testDb, { migrationsFolder: './drizzle' });
  
  return testDb;
}

export async function teardownTestDb() {
  await testPool.end();
}

export async function resetTestDb() {
  // Clear all tables
  await testDb.delete(schema.posts);
  await testDb.delete(schema.users);
}
user.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, teardownTestDb, resetTestDb } from './test-utils';
import { createUser } from './users';

let db: Awaited<ReturnType<typeof setupTestDb>>;

beforeAll(async () => {
  db = await setupTestDb();
});

afterAll(async () => {
  await teardownTestDb();
});

beforeEach(async () => {
  await resetTestDb();
});

describe('User Creation', () => {
  it('should create a user', async () => {
    const result = await createUser('[email protected]', 'Test User');
    
    expect(result.success).toBe(true);
    expect(result.data?.email).toBe('[email protected]');
  });
  
  it('should reject duplicate emails', async () => {
    await createUser('[email protected]', 'User 1');
    const result = await createUser('[email protected]', 'User 2');
    
    expect(result.success).toBe(false);
    expect(result.error).toContain('already exists');
  });
});

Security

SQL Injection Prevention

Drizzle prevents SQL injection by default, but be careful with raw SQL:
import { sql, eq } from 'drizzle-orm';
import { users } from './schema';

// Safe: Parameterized query
const user = await db.select()
  .from(users)
  .where(eq(users.email, userInput));

// Dangerous: String interpolation
const unsafe = await db.execute(
  sql`SELECT * FROM users WHERE email = '${userInput}'` // DON'T DO THIS
);

// Safe: Use sql.placeholder or sql.raw carefully
const safe = await db.execute(
  sql`SELECT * FROM users WHERE email = ${userInput}` // Safe: parameterized
);

Row-Level Security

Implement application-level RLS:
import { eq, and } from 'drizzle-orm';
import { posts } from './schema';

export async function getUserPosts(userId: number, requestingUserId: number) {
  return await db.select()
    .from(posts)
    .where(
      and(
        eq(posts.authorId, userId),
        // Only show public posts or posts owned by requesting user
        or(
          eq(posts.isPublic, true),
          eq(posts.authorId, requestingUserId)
        )
      )
    );
}

Sensitive Data

import { pgTable, serial, text } from 'drizzle-orm/pg-core';
import bcrypt from 'bcrypt';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash').notNull(),
});

// Never return password hashes
export async function getUserById(id: number) {
  return await db.select({
    id: users.id,
    email: users.email,
    // Omit passwordHash
  }).from(users).where(eq(users.id, id));
}

// Hash passwords before storing
export async function createUser(email: string, password: string) {
  const passwordHash = await bcrypt.hash(password, 10);
  
  const [user] = await db.insert(users)
    .values({ email, passwordHash })
    .returning({ id: users.id, email: users.email });
  
  return user;
}

Performance Monitoring

Custom Logger

import { DefaultLogger, type Logger } from 'drizzle-orm';

class PerformanceLogger extends DefaultLogger {
  override logQuery(query: string, params: unknown[]): void {
    const start = Date.now();
    
    // Log slow queries
    setTimeout(() => {
      const duration = Date.now() - start;
      if (duration > 100) {
        console.warn(`Slow query (${duration}ms):`, query);
        console.warn('Params:', params);
      }
    }, 0);
    
    super.logQuery(query, params);
  }
}

const db = drizzle(pool, {
  logger: new PerformanceLogger(),
});

Query Execution Time

export async function withTiming<T>(
  queryFn: () => Promise<T>,
  queryName: string
): Promise<T> {
  const start = Date.now();
  try {
    const result = await queryFn();
    const duration = Date.now() - start;
    console.log(`${queryName} took ${duration}ms`);
    return result;
  } catch (error) {
    const duration = Date.now() - start;
    console.error(`${queryName} failed after ${duration}ms:`, error);
    throw error;
  }
}

// Usage
const users = await withTiming(
  () => db.select().from(usersTable),
  'fetch_all_users'
);

Next Steps

Schema Design

Deep dive into schema definition

Query API

Master the query builder

Serverless Guide

Deploy to serverless platforms

Edge Runtime

Run at the edge worldwide

Build docs developers (and LLMs) love