Published on

Drizzle ORM in Production — Type-Safe SQL Without the Magic

Authors

Introduction

Drizzle ORM provides type-safe SQL generation without hiding the actual SQL from you. Unlike ORMs that use opaque query builders, Drizzle generates explicit SQL you can reason about and optimize. This post covers schema definition, migrations with drizzle-kit, complex queries with joins and subqueries, transactions, prepared statements for performance, comparisons with Prisma, and query logging for debugging.

Schema Definition with Type Safety

Define your entire database schema in TypeScript. Drizzle generates migrations automatically, and types flow through all queries.

import {
  pgTable,
  text,
  integer,
  timestamp,
  boolean,
  uuid,
  index,
  unique,
  foreignKey,
  decimal,
  jsonb,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { sql } from 'drizzle-orm';

export const users = pgTable(
  'users',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    email: text('email').notNull().unique(),
    username: text('username').notNull(),
    passwordHash: text('password_hash').notNull(),
    role: text('role', { enum: ['admin', 'user', 'moderator'] }).default('user'),
    isActive: boolean('is_active').default(true),
    metadata: jsonb('metadata'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  table => [
    index('idx_users_email').on(table.email),
    index('idx_users_username').on(table.username),
    index('idx_users_created_at').on(table.createdAt),
  ]
);

export const posts = pgTable(
  'posts',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    authorId: uuid('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text('title').notNull(),
    slug: text('slug').notNull().unique(),
    content: text('content').notNull(),
    excerpt: text('excerpt'),
    published: boolean('published').default(false),
    viewCount: integer('view_count').default(0),
    publishedAt: timestamp('published_at'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  table => [
    index('idx_posts_author_id').on(table.authorId),
    index('idx_posts_slug').on(table.slug),
    index('idx_posts_published_created').on(table.published, table.createdAt),
  ]
);

export const comments = pgTable(
  'comments',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    postId: uuid('post_id')
      .notNull()
      .references(() => posts.id, { onDelete: 'cascade' }),
    authorId: uuid('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    content: text('content').notNull(),
    parentCommentId: uuid('parent_comment_id').references(
      () => comments.id,
      { onDelete: 'cascade' }
    ),
    approved: boolean('approved').default(false),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  table => [
    index('idx_comments_post_id').on(table.postId),
    index('idx_comments_author_id').on(table.authorId),
    index('idx_comments_parent_comment_id').on(table.parentCommentId),
  ]
);

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],
  }),
  parentComment: one(comments, {
    fields: [comments.parentCommentId],
    references: [comments.id],
  }),
}));

Migrations with drizzle-kit

Generate migrations automatically, then apply them with full version control.

# drizzle.config.ts
import type { Config } from 'drizzle-kit';

export default {
  schema: './src/db/schema.ts',
  out: './src/db/migrations',
  driver: 'pg',
  dbCredentials: {
    connectionString: process.env.DATABASE_URL!,
  },
  migrations: {
    table: '__drizzle_migrations__',
    schema: 'public',
  },
} satisfies Config;

# Generate migration file
drizzle-kit generate:pg

# Apply migrations
drizzle-kit migrate:pg

Generated migration maintains explicitness:

CREATE TABLE IF NOT EXISTS "users" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  "email" text NOT NULL UNIQUE,
  "username" text NOT NULL,
  "password_hash" text NOT NULL,
  "role" text DEFAULT 'user',
  "is_active" boolean DEFAULT true,
  "metadata" jsonb,
  "created_at" timestamp DEFAULT now() NOT NULL,
  "updated_at" timestamp DEFAULT now() NOT NULL
);

CREATE INDEX IF NOT EXISTS "idx_users_email" ON "users" ("email");
CREATE INDEX IF NOT EXISTS "idx_users_created_at" ON "users" ("created_at");

ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk"
  FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade;

Complex Queries with Joins and Subqueries

Drizzle generates optimized SQL for complex queries with type safety.

import { db } from './db';
import { users, posts, comments } from './schema';
import { eq, desc, and, gt, inArray } from 'drizzle-orm';
import { sql } from 'drizzle-orm';

// Complex join with aggregation
const userPostStats = await db
  .select({
    userId: users.id,
    username: users.username,
    email: users.email,
    postCount: sql<number>`cast(count(${posts.id}) as integer)`,
    totalViews: sql<number>`coalesce(sum(${posts.viewCount}), 0)`,
    avgComments: sql<string>`round(avg(comment_count)::numeric, 2)`,
  })
  .from(users)
  .leftJoin(posts, eq(posts.authorId, users.id))
  .groupBy(users.id, users.username, users.email)
  .having(gt(sql`count(${posts.id})`, 0))
  .orderBy(desc(sql`count(${posts.id})`));

// Subquery for recent comments
const recentCommentSubquery = db
  .select({ postId: comments.postId })
  .from(comments)
  .where(gt(comments.createdAt, sql`now() - interval '7 days'`))
  .as('recent_comments');

const trendingPosts = await db
  .select({
    id: posts.id,
    title: posts.title,
    authorName: users.username,
    commentCount: sql<number>`count(${comments.id})`,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .innerJoin(
    recentCommentSubquery,
    eq(posts.id, recentCommentSubquery.postId)
  )
  .leftJoin(comments, eq(comments.postId, posts.id))
  .where(eq(posts.published, true))
  .groupBy(posts.id, posts.title, users.username)
  .orderBy(desc(sql`count(${comments.id})`))
  .limit(20);

// Recursive CTE for comment threads
const commentThreads = await db.execute(sql`
  WITH RECURSIVE comment_tree AS (
    SELECT id, post_id, author_id, content, parent_comment_id, created_at, 0 as depth
    FROM comments
    WHERE parent_comment_id IS NULL

    UNION ALL

    SELECT c.id, c.post_id, c.author_id, c.content, c.parent_comment_id, c.created_at, ct.depth + 1
    FROM comments c
    INNER JOIN comment_tree ct ON c.parent_comment_id = ct.id
    WHERE ct.depth < 10
  )
  SELECT * FROM comment_tree ORDER BY depth, created_at
`);

Transactions and Atomic Operations

Execute multiple operations atomically with rollback on error.

import { db } from './db';

const transferPoints = async (fromUserId: string, toUserId: string, amount: number) => {
  return await db.transaction(async trx => {
    // Deduct from sender
    await trx
      .update(users)
      .set({ points: sql`points - ${amount}` })
      .where(eq(users.id, fromUserId));

    // Add to recipient
    await trx
      .update(users)
      .set({ points: sql`points + ${amount}` })
      .where(eq(users.id, toUserId));

    // Log transaction
    await trx.insert(auditLog).values({
      type: 'point_transfer',
      fromUserId,
      toUserId,
      amount,
      timestamp: new Date(),
    });
  });
};

const bulkPublishPosts = async (postIds: string[], userId: string) => {
  return await db.transaction(async trx => {
    const updated = await trx
      .update(posts)
      .set({ published: true, publishedAt: new Date() })
      .where(
        and(
          inArray(posts.id, postIds),
          eq(posts.authorId, userId)
        )
      )
      .returning();

    await trx.insert(auditLog).values(
      updated.map(post => ({
        action: 'post_published',
        postId: post.id,
        userId,
        timestamp: new Date(),
      }))
    );

    return updated;
  });
};

Prepared Statements for Performance

Pre-compile queries for repeated execution with different parameters.

import { db } from './db';
import { eq } from 'drizzle-orm';

// Prepare query
const getUserById = db
  .select()
  .from(users)
  .where(eq(users.id, sql.placeholder('userId')))
  .prepare('getUserById');

// Execute with parameter binding
const user1 = await getUserById.execute({ userId: 'uuid-1' });
const user2 = await getUserById.execute({ userId: 'uuid-2' });

// Batch prepared queries
const getUsersByIds = db
  .select()
  .from(users)
  .where(inArray(users.id, sql.placeholder('ids')))
  .prepare('getUsersByIds');

const users = await getUsersByIds.execute({ ids: ['uuid-1', 'uuid-2', 'uuid-3'] });

// Complex prepared query with joins
const getPostWithComments = db
  .select({
    post: posts,
    comments: {
      id: comments.id,
      content: comments.content,
      authorName: users.username,
    },
  })
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id))
  .leftJoin(comments, eq(comments.postId, posts.id))
  .where(eq(posts.id, sql.placeholder('postId')))
  .prepare('getPostWithComments');

const postWithComments = await getPostWithComments.execute({
  postId: 'post-uuid',
});

Drizzle vs Prisma Decision Matrix

FeatureDrizzlePrisma
SQL visibilityExplicit SQLHidden query builder
Type safetyFullFull
PerformanceThin wrapper, fasterAbstraction overhead
MigrationsManual SQL reviewAutomated, opaque
Learning curveSQL knowledge requiredLower if SQL unfamiliar
Complex queriesNative SQL when neededQuery builder only
Prepared statementsBuilt-inNot exposed
Relations loadingManual controlAutomatic with risk
Database featuresUse raw SQLLimited support

Choose Drizzle for performance-critical systems and complex queries. Choose Prisma for rapid development and teams unfamiliar with SQL.

Query Logging for Debugging

Enable SQL logging to verify generated queries and spot N+1 problems.

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

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

export const db = drizzle(pool, {
  logger: process.env.NODE_ENV === 'development',
});

// Custom logger for production debugging
const customLogger = {
  logQuery: (query: string, params: unknown[]) => {
    if (process.env.LOG_SLOW_QUERIES) {
      const start = performance.now();
      // Monitor execution time
      console.log(`[SQL] ${query}`, params);
    }
  },
};

export const db = drizzle(pool, { logger: customLogger });

// Middleware to track query counts per request
import { NextRequest, NextResponse } from 'next/server';

const queryLogger = {
  queryCount: 0,
  logQuery: (query: string) => {
    queryLogger.queryCount++;
    console.log(`Query ${queryLogger.queryCount}: ${query}`);
  },
};

export async function middleware(request: NextRequest) {
  queryLogger.queryCount = 0;

  const response = await NextResponse.next();

  if (queryLogger.queryCount > 10) {
    console.warn(
      `[N+1 WARNING] ${queryLogger.queryCount} queries for ${request.nextUrl.pathname}`
    );
  }

  response.headers.set('X-Query-Count', queryLogger.queryCount.toString());
  return response;
}

Production Checklist

  • Migrations versioned in git before deployment
  • Indexes created for all foreign keys and WHERE/ORDER BY columns
  • Prepared statements used for frequently-executed queries
  • N+1 query problems identified with query logging
  • Transactions wrap multi-table operations
  • Connection pooling configured (PgBouncer or Postgres built-in)
  • Query timeouts set to prevent runaway queries
  • Audit logs or event tables track important mutations

Conclusion

Drizzle ORM strikes a balance between type safety and control. Write explicit SQL when it matters, generate type-safe queries automatically, and optimize with prepared statements and indexes. Use migrations to version your schema, and enable query logging to find performance issues before they reach production.