← Back to DevBytes

Drizzle ORM from Beginner to Expert: A Learning Path

Introduction to Drizzle ORM

Drizzle ORM is a modern TypeScript-first Object-Relational Mapping library designed for Node.js and edge runtime environments. Unlike traditional ORMs that abstract SQL away, Drizzle embraces SQL as a first-class citizen, giving developers full control over their queries while providing type safety and a delightful developer experience. Think of it as a "query builder on steroids" — you write SQL-like queries in TypeScript, and Drizzle generates the actual SQL, ensuring type safety between your database schema and your application code.

What Sets Drizzle Apart

Drizzle ORM distinguishes itself through several key design philosophies:

Why Drizzle Matters in Modern Development

In the era of serverless computing and edge runtimes, cold starts matter. Traditional ORMs like Prisma or TypeORM carry significant initialization overhead due to their heavy client engines and schema generation processes. Drizzle's lightweight architecture — typically under 100KB — means faster cold starts and lower memory footprints. Furthermore, its SQL-first approach empowers developers to leverage database-specific features like PostgreSQL's window functions, CTEs, or JSONB operations without fighting against an abstraction layer that may not support them fully.

Setting Up Your First Drizzle Project

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Let's walk through creating a complete Drizzle ORM project from scratch with PostgreSQL. You'll need Node.js 18+ and a PostgreSQL instance running (local or via Docker).

Step 1: Initialize the Project

mkdir drizzle-tutorial
cd drizzle-tutorial
npm init -y
npm install drizzle-orm drizzle-kit pg dotenv
npm install -D @types/pg typescript tsx

Step 2: Configure TypeScript

Create a tsconfig.json file with strict settings that complement Drizzle's type system:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src/**/*"]
}

Step 3: Define Your Database Schema

Create a src/schema.ts file. Drizzle uses a declarative syntax where you describe tables using column builders that mirror actual database types:

import { pgTable, serial, text, integer, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';

// Define an enum for user roles
export const userRoleEnum = pgEnum('user_role', ['admin', 'editor', 'viewer']);

// Users table
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  role: userRoleEnum('role').default('viewer').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

// Posts table with a foreign key relationship
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  title: text('title').notNull(),
  content: text('content').notNull(),
  published: boolean('published').default(false).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

// Comments table
export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  postId: integer('post_id')
    .notNull()
    .references(() => posts.id, { onDelete: 'cascade' }),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  body: text('body').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

Step 4: Create the Database Connection

Set up the connection in src/db.ts. We'll use a connection pool for production readiness:

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

// Create a connection pool
const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres',
  database: process.env.DB_NAME || 'drizzle_tutorial',
});

// Initialize Drizzle with the pool and schema
export const db = drizzle(pool, { schema });

// Export the schema for use in queries
export type Database = typeof db;

Step 5: Run Migrations with Drizzle Kit

Create a drizzle.config.ts file to configure the migration tool:

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT || '5432'),
    user: process.env.DB_USER || 'postgres',
    password: process.env.DB_PASSWORD || 'postgres',
    database: process.env.DB_NAME || 'drizzle_tutorial',
  },
});

Now generate and push migrations:

npx drizzle-kit generate
npx drizzle-kit push

The generate command creates SQL migration files in the ./drizzle directory. The push command applies them directly to your database — ideal for development. For production, you'd use migrate instead, which runs pending migrations from the folder.

Basic CRUD Operations

With the schema and connection in place, let's explore the fundamental query patterns. Drizzle's query syntax is intentionally close to SQL, making it intuitive for developers familiar with database query languages.

Inserting Records

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

// Insert a single user
const newUser = await db.insert(users).values({
  email: 'alice@example.com',
  name: 'Alice Johnson',
  role: 'admin',
}).returning();

console.log('Created user:', newUser[0].id);

// Insert multiple users at once
const multipleUsers = await db.insert(users).values([
  { email: 'bob@example.com', name: 'Bob Smith', role: 'editor' },
  { email: 'carol@example.com', name: 'Carol Williams', role: 'viewer' },
]).returning();

// Insert with conflict handling (upsert)
const upsertedUser = await db.insert(users)
  .values({ email: 'alice@example.com', name: 'Alice Updated', role: 'admin' })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: 'Alice Updated', updatedAt: new Date() },
  })
  .returning();

Querying Records

// Select all users
const allUsers = await db.select().from(users);
console.log('All users:', allUsers);

// Select with filters (WHERE clause)
const admins = await db.select().from(users).where(
  eq(users.role, 'admin')
);

// Select specific columns
const userEmails = await db.select({
  id: users.id,
  email: users.email,
}).from(users);

// Complex WHERE with multiple conditions
const filteredUsers = await db.select().from(users).where(
  and(
    eq(users.role, 'editor'),
    gte(users.createdAt, new Date('2024-01-01'))
  )
);

// LIKE pattern matching
const usersWithGmail = await db.select().from(users).where(
  like(users.email, '%@gmail.com')
);

// IN clause
const specificUsers = await db.select().from(users).where(
  inArray(users.id, [1, 2, 3])
);

Notice how Drizzle uses imported operator functions like eq, and, gte, like, and inArray. These come from drizzle-orm and provide type-safe comparisons that match the column data types.

Updating Records

// Simple update
await db.update(users)
  .set({ name: 'Alice Updated' })
  .where(eq(users.id, 1));

// Update multiple fields with a returning clause
const updatedUser = await db.update(users)
  .set({
    name: 'Bob Updated',
    updatedAt: new Date(),
  })
  .where(eq(users.id, 2))
  .returning();

// Conditional update with computed values
await db.update(posts)
  .set({
    published: sql`NOT ${posts.published}`,
  })
  .where(eq(posts.id, 1));

Deleting Records

// Delete a single record
await db.delete(users).where(eq(users.id, 3));

// Delete with returning to get deleted data
const deletedPosts = await db.delete(posts)
  .where(and(
    eq(posts.published, false),
    lt(posts.createdAt, new Date('2023-01-01'))
  ))
  .returning();

console.log('Deleted posts:', deletedPosts.length);

// Delete all records (use with caution!)
await db.delete(comments);

Relationships and Joins

Drizzle offers multiple approaches to handle relationships, from explicit SQL joins to a powerful relational query API that automatically infers connections from your schema definitions.

Defining Relationships

First, declare relations in your schema file. Add this to src/schema.ts:

import { relations } from 'drizzle-orm';

// User relations
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
  comments: many(comments),
}));

// Post relations
export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.userId],
    references: [users.id],
  }),
  comments: many(comments),
}));

// Comment relations
export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
  author: one(users, {
    fields: [comments.userId],
    references: [users.id],
  }),
}));

Using the Relational Query API

The relational query API lets you fetch nested data in a single query, automatically constructing the necessary JOINs:

import { db } from './db';

// Fetch users with their posts (one-to-many)
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: true,
  },
});
console.log(usersWithPosts[0].posts); // Array of posts for the first user

// Fetch posts with author and comments (nested relations)
const postsWithEverything = await db.query.posts.findMany({
  with: {
    author: true,
    comments: {
      with: {
        author: true,
      },
    },
  },
  where: (posts, { eq }) => eq(posts.published, true),
});

// Fetch with conditional relation loading
const userWithRecentPosts = await db.query.users.findFirst({
  where: (users, { eq }) => eq(users.id, 1),
  with: {
    posts: {
      where: (posts, { gte }) => gte(posts.createdAt, new Date('2024-01-01')),
      orderBy: (posts, { desc }) => desc(posts.createdAt),
      limit: 5,
    },
  },
});

Manual SQL Joins

For complex scenarios or when you need fine-grained control, use explicit joins:

// Inner join
const result = await db
  .select({
    postTitle: posts.title,
    authorName: users.name,
    commentBody: comments.body,
  })
  .from(posts)
  .innerJoin(users, eq(posts.userId, users.id))
  .leftJoin(comments, eq(posts.id, comments.postId))
  .where(eq(posts.published, true));

// Using the select overload for joined tables
const joinedData = await db
  .select()
  .from(posts)
  .leftJoin(users, eq(posts.userId, users.id))
  .where(eq(users.role, 'admin'));

Intermediate Patterns

As your application grows, you'll need more sophisticated query patterns. Drizzle handles these gracefully without forcing you to escape to raw SQL — though you always have that option.

Transactions

Transactions ensure atomic operations. Drizzle provides a clean API for transaction handling:

// Basic transaction
await db.transaction(async (tx) => {
  const user = await tx.insert(users).values({
    email: 'dave@example.com',
    name: 'Dave Brown',
    role: 'editor',
  }).returning();

  await tx.insert(posts).values({
    userId: user[0].id,
    title: 'Dave\'s First Post',
    content: 'Hello world!',
    published: true,
  });

  return user[0];
});

// Transaction with rollback on error
try {
  await db.transaction(async (tx) => {
    // Deduct from one account
    await tx.update(users)
      .set({ /* balance deduction */ })
      .where(eq(users.id, 1));

    // Add to another account
    await tx.update(users)
      .set({ /* balance addition */ })
      .where(eq(users.id, 2));

    // If any query fails, the entire transaction rolls back automatically
  });
} catch (error) {
  console.error('Transaction failed and was rolled back:', error);
}

// Set transaction isolation level
await db.transaction(async (tx) => {
  // Your queries here
}, {
  isolationLevel: 'serializable',
});

Aggregation and Grouping

import { count, sum, avg, max, min } from 'drizzle-orm';

// Count posts per user
const postCounts = await db
  .select({
    userId: posts.userId,
    userName: users.name,
    postCount: count(posts.id),
  })
  .from(posts)
  .innerJoin(users, eq(posts.userId, users.id))
  .groupBy(posts.userId, users.name)
  .having(({ postCount }) => gte(postCount, 2));

// Complex aggregations
const stats = await db
  .select({
    authorId: posts.userId,
    totalPosts: count(posts.id),
    avgTitleLength: avg(sql`length(${posts.title})`),
    latestPost: max(posts.createdAt),
  })
  .from(posts)
  .groupBy(posts.userId);

SQL Expressions and Raw SQL

When you need database-specific features or complex expressions, Drizzle lets you write raw SQL fragments that integrate seamlessly with the type system:

import { sql } from 'drizzle-orm';

// Using SQL expressions in select
const usersWithInitial = await db
  .select({
    id: users.id,
    name: users.name,
    initial: sql`LEFT(${users.name}, 1)`.as('initial'),
    daysSinceCreation: sql`
      EXTRACT(DAY FROM NOW() - ${users.createdAt})
    `.as('days_since_creation'),
  })
  .from(users);

// Raw SQL in WHERE clauses
const recentActiveUsers = await db
  .select()
  .from(users)
  .where(sql`
    ${users.createdAt} > NOW() - INTERVAL '30 days'
    AND ${users.role} IN ('admin', 'editor')
  `);

// Execute completely raw SQL with parameterized queries
const rawResult = await db.execute(sql`
  SELECT pg_stat_reset();
  SELECT pg_stat_get_dead_tuples('users'::regclass) as dead_tuples;
`);

Prepared Statements and Batch Operations

// Prepared statements for repeated queries
const getUserByEmail = db.select()
  .from(users)
  .where(eq(users.email, sql.placeholder('email')))
  .prepare('getUserByEmail');

// Execute the prepared statement
const user = await getUserByEmail.execute({ email: 'alice@example.com' });

// Batch insert for high-performance bulk operations
const batchUsers = await db.insert(users).values(
  Array.from({ length: 100 }, (_, i) => ({
    email: `user${i}@example.com`,
    name: `User ${i}`,
    role: 'viewer' as const,
  }))
).returning();

Advanced Topics

Now we venture into expert territory. These patterns unlock Drizzle's full potential for large-scale, production-grade applications.

Custom Schema with Views and Indexes

import { pgView, pgMaterializedView, index, uniqueIndex } from 'drizzle-orm/pg-core';

// Define a PostgreSQL view
export const activeUsersView = pgView('active_users_view', {
  id: integer('id'),
  name: text('name'),
  email: text('email'),
  postCount: integer('post_count'),
}).as(
  db.select({
    id: users.id,
    name: users.name,
    email: users.email,
    postCount: count(posts.id).as('post_count'),
  })
  .from(users)
  .leftJoin(posts, eq(users.id, posts.userId))
  .where(eq(users.role, 'admin'))
  .groupBy(users.id, users.name, users.email)
);

// Define indexes for performance
export const userEmailIndex = uniqueIndex('user_email_idx')
  .on(users, users.email);

export const postCreatedAtIndex = index('post_created_at_idx')
  .on(posts, posts.createdAt.desc());

// Composite index
export const userRoleCreatedIndex = index('user_role_created_idx')
  .on(users, users.role, users.createdAt.desc());

Schema Versioning and Migration Strategies

For production environments, you need a robust migration workflow. Drizzle Kit supports generating migration files that you can version in git:

// drizzle.config.ts with production settings
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/schema.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    host: process.env.DB_HOST!,
    port: parseInt(process.env.DB_PORT!),
    user: process.env.DB_USER!,
    password: process.env.DB_PASSWORD!,
    database: process.env.DB_NAME!,
    ssl: true,
  },
  // Enable strict mode for production migrations
  strict: true,
  verbose: true,
});

Create a migration runner script for programmatic migrations in CI/CD pipelines:

// src/migrate.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';

async function runMigrations() {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const db = drizzle(pool);

  // This runs all pending migrations from the specified folder
  await migrate(db, {
    migrationsFolder: './drizzle/migrations',
    migrationsTable: '__drizzle_migrations',
  });

  console.log('Migrations completed successfully');
  await pool.end();
}

runMigrations().catch((err) => {
  console.error('Migration failed:', err);
  process.exit(1);
});

Working with JSON and JSONB Columns

PostgreSQL's JSONB type is fully supported with type inference:

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

// Define a table with JSONB column
export const documents = pgTable('documents', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  metadata: jsonb('metadata').notNull().$type<{
    tags: string[];
    priority: number;
    archived: boolean;
  }>(),
});

// Insert with typed JSON
await db.insert(documents).values({
  title: 'Project Alpha',
  metadata: {
    tags: ['urgent', 'client-facing'],
    priority: 5,
    archived: false,
  },
});

// Query JSONB fields
const highPriorityDocs = await db
  .select()
  .from(documents)
  .where(
    sql`${documents.metadata}->>'priority' >= '4'`
  );

// Update nested JSON
await db.update(documents)
  .set({
    metadata: sql`jsonb_set(
      ${documents.metadata},
      '{archived}',
      'true',
      true
    )`,
  })
  .where(eq(documents.id, 1));

Soft Deletes and Temporal Data

// Extending the schema for soft deletes
import { boolean, timestamp } from 'drizzle-orm/pg-core';

// Add soft delete columns to your tables
export const softDeleteUsers = pgTable('soft_delete_users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull(),
  name: text('name').notNull(),
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
  isDeleted: boolean('is_deleted').default(false).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

// Query helper that automatically excludes deleted records
function notDeleted(column: T) {
  return and(
    eq(column.isDeleted, false),
    sql`${column.deletedAt} IS NULL`
  );
}

// Usage
const activeUsers = await db
  .select()
  .from(softDeleteUsers)
  .where(notDeleted(softDeleteUsers));

// Soft delete operation
await db.update(softDeleteUsers)
  .set({
    isDeleted: true,
    deletedAt: new Date(),
  })
  .where(eq(softDeleteUsers.id, 1));

Multi-Tenancy Patterns

// Schema-based multi-tenancy
export function createTenantSchema(tenantId: string) {
  return {
    tenantUsers: pgTable(`tenant_${tenantId}_users`, {
      id: serial('id').primaryKey(),
      email: text('email').notNull(),
      settings: jsonb('settings').$type>(),
    }),
    tenantData: pgTable(`tenant_${tenantId}_data`, {
      id: serial('id').primaryKey(),
      userId: integer('user_id').notNull(),
      payload: jsonb('payload').notNull(),
    }),
  };
}

// Column-based multi-tenancy with Row-Level Security
export const tenantAwarePosts = pgTable('tenant_aware_posts', {
  id: serial('id').primaryKey(),
  tenantId: text('tenant_id').notNull(),
  title: text('title').notNull(),
  content: text('content').notNull(),
});

// Query wrapper that automatically scopes by tenant
function scopedQuery(db: Database, tenantId: string) {
  return db.select()
    .from(tenantAwarePosts)
    .where(eq(tenantAwarePosts.tenantId, tenantId));
}

Performance Optimization Techniques

For high-throughput applications, consider these optimization patterns:

// Use select to fetch only needed columns (reduces I/O)
const lightweightUsers = await db
  .select({
    id: users.id,
    email: users.email,
  })
  .from(users)
  .where(eq(users.role, 'viewer'))
  .limit(50);

// Leverage database indexes with proper WHERE clauses
// Assuming an index on (role, created_at), this query uses it efficiently
const indexedQuery = await db
  .select()
  .from(users)
  .where(and(
    eq(users.role, 'admin'),
    gte(users.createdAt, new Date('2024-01-01'))
  ))
  .orderBy(users.createdAt);

// Use streaming for large result sets
import { createCursor } from 'drizzle-orm';

const cursor = await db
  .select()
  .from(posts)
  .where(eq(posts.published, true))
  .orderBy(posts.createdAt)
  .limit(1000);

// Process in batches to manage memory
for await (const row of cursor.stream()) {
  await processRow(row);
}

// Connection pooling configuration
const pool = new Pool({
  max: 20,           // Maximum pool size
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
  maxUses: 1000,     // Close connections after 1000 uses to prevent memory leaks
});

Testing with Drizzle

A robust testing strategy ensures your database layer works correctly:

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

export async function createTestDatabase() {
  // Use a separate test database
  const pool = new Pool({
    connectionString: process.env.TEST_DATABASE_URL,
  });

  const db = drizzle(pool, { schema });

  // Run migrations before tests
  await migrate(db, {
    migrationsFolder: './drizzle/migrations',
  });

  return { db, pool };
}

export async function cleanupDatabase(db: Database) {
  // Truncate all tables in correct order (respecting foreign keys)
  await db.execute(sql`
    TRUNCATE TABLE comments, posts, users RESTART IDENTITY CASCADE;
  `);
}

// Example test using Vitest
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

describe('User Repository', () => {
  let db: Database;
  let pool: Pool;

  beforeEach(async () => {
    const test = await createTestDatabase();
    db = test.db;
    pool = test.pool;
  });

  afterEach(async () => {
    await cleanupDatabase(db);
    await pool.end();
  });

  it('should create a user', async () => {
    const user = await db.insert(users).values({
      email: 'test@example.com',
      name: 'Test User',
      role: 'viewer',
    }).returning();

    expect(user[0].email).toBe('test@example.com');
    expect(user[0].role).toBe('viewer');
  });

  it('should enforce unique email constraint', async () => {
    await db.insert(users).values({
      email: 'duplicate@example.com',
      name: 'First User',
      role: 'viewer',
    });

    await expect(
      db.insert(users).values({
        email: 'duplicate@example.com',
        name: 'Second User',
        role: 'editor',
      })
    ).rejects.toThrow();
  });
});

Best Practices and Expert Insights

After working extensively with Drizzle ORM in production environments, several patterns emerge as particularly valuable:

Schema Organization

For large applications, split your schema across multiple files organized by domain. Create an index.ts barrel file that re-exports everything:

// src/schema/index.ts
export * from './users';
export * from './posts';
export * from './comments';
export * from './relations';

// In each domain file, define tables independently
// src/schema/users.ts
import { pgTable, serial, text } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  // ...other columns
});

Type-Safe Query Builders

Create reusable query fragments that maintain type safety across your application:

// src/queries/user-queries.ts
import { db } from '../db';
import { users, posts } from '../schema';
import { eq, and, gte, desc } from 'drizzle-orm';

export const UserQueries = {
  // Pre-built query fragments
  activeAdmins: db.select().from(users).where(
    and(eq(users.role, 'admin'), eq(users.isDeleted, false))
  ).prepare('activeAdmins'),

  // Parameterized query builder
  byRole: (role: 'admin' | 'editor' | 'viewer') =>
    db.select().from(users).where(eq(users.role, role)).prepare(`usersByRole_${role}`),

  // Complex aggregation query
  topContributors: db.select({
    user: users,
    postCount: count(posts.id),
  })
    .from(users)
    .leftJoin(posts, eq(users.id, posts.userId))
    .groupBy(users.id)
    .orderBy(desc(count(posts.id)))
    .limit(10)
    .prepare('topContributors'),
};

Error Handling Patterns

// Centralized error handling wrapper
async function withErrorHandling(
  operation: () => Promise,
  context: string
): Promise {
  try {
    return await operation();
  } catch (error) {
    if (error instanceof Error) {
      if (error.message.includes('unique constraint')) {
        throw new DatabaseError('DUPLICATE_ENTRY', error.message, context);
      }
      if (error.message.includes('foreign key')) {
        throw new DatabaseError('FK_VIOLATION', error.message, context);
      }
    }
    throw new DatabaseError('UNKNOWN', String(error), context);
  }
}

class DatabaseError extends Error {
  constructor(
    public code: string,
    message: string,
    public context: string
  ) {
    super(`[${code}] ${message} (context: ${context})`);
    this.name = 'DatabaseError';
  }
}

// Usage
const user = await withErrorHandling(
  () => db.insert(users).values({
    email: 'new@example.com',
    name: 'New User',
    role: 'viewer',
  }).returning(),
  'createUser'
);

Logging and Monitoring

// Query logging middleware
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool, Query } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

// Monkey-patch to log queries (for development)
const originalQuery = pool.query.bind(pool);
pool.query = (text: string, params?: any[]) => {
  const start = Date.now();
  console.log('Query:', text.replace(/\s+/g, ' ').trim());
  if (params?.length) console.log('Params:', params);

  return originalQuery(text, params).then((result) => {
    const duration = Date.now() - start;
    console.log(`Duration: ${duration}ms, Rows: ${result.rowCount ?? 'N/A'}`);
    if (duration > 1000) {
      console.warn('Slow query detected:', { text, duration });
    }
    return result;
  });
};

const db = drizzle(pool, { schema });

Edge Runtime Compatibility

One of Drizzle's standout features is its edge runtime support. For Cloudflare Workers, Vercel Edge, or Deno Deploy, use the appropriate driver:

// For Cloudflare Workers with Neon or other HTTP-based Postgres
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql, { schema });

// For PlanetScale (MySQL-compatible) on edge
import { drizzle } from 'drizzle-orm/planetscale-serverless';
import { connect } from '@planetscale/database';

const connection = connect({
  host: process.env.DATABASE_HOST,
  username: process.env.DATABASE_USERNAME,
  password: process.env.DATABASE_PASSWORD,
});
const db = drizzle(connection, { schema });

// For SQLite on edge (Turso/libsql)
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';

const client = createClient({
  url: process.env.DATABASE_URL!,
  authToken: process.env.DATABASE_AUTH_TOKEN,
});
const db = drizzle(client, { schema });

Conclusion

Drizzle ORM represents a thoughtful evolution in the TypeScript database landscape. By embracing SQL rather than abstracting it away, Drizzle gives developers the best of both worlds: the ergonomics and type safety of a modern ORM combined with the raw power and flexibility of direct database access. Its lightweight footprint makes it ideal for the serverless and edge computing paradigms that increasingly define modern application architecture.

The learning path from beginner to expert follows a natural progression: start with basic schema definitions and CRUD operations, move through relationships and joins, explore intermediate patterns like transactions and aggregations, and finally master advanced topics such as custom views, performance optimization, multi-tenancy, and edge deployment. Throughout this journey, D

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles