Drizzle
06 / 06

Advanced Patterns & Type Safety

Drizzle: Advanced Patterns & Type Safety

Drizzle with Zod (drizzle-zod)

import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod';
import { z } from 'zod';
import { pages } from '@/db/schema';

// Auto-generate Zod schemas from Drizzle table
const insertPageSchema = createInsertSchema(pages, {
  // Override or extend individual fields
  title: z.string().min(1).max(200),
  slug: z.string().regex(/^[a-z0-9-]+$/),
});

const selectPageSchema = createSelectSchema(pages);
const updatePageSchema = createUpdateSchema(pages).omit({ id: true, createdAt: true });

// Use in API route validation
export async function POST(req: Request) {
  const body = await req.json();
  const parsed = insertPageSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ errors: parsed.error.format() }, { status: 400 });
  }
  const page = await db.insert(pages).values(parsed.data).returning();
  return Response.json(page[0], { status: 201 });
}

Prepared Statements

import { placeholder } from 'drizzle-orm';

// Prepare once, execute many times — better performance
const getUserById = db.select()
  .from(users)
  .where(eq(users.id, placeholder('id')))
  .prepare('get_user_by_id');

// Execute with type-safe parameters
const user = await getUserById.execute({ id: userId });

// Prepared statement with multiple params
const getFolderPages = db.select()
  .from(pages)
  .where(and(
    eq(pages.folderId, placeholder('folderId')),
    eq(pages.status, placeholder('status')),
  ))
  .orderBy(desc(pages.updatedAt))
  .prepare('get_folder_pages');

const learningPages = await getFolderPages.execute({
  folderId: folder.id,
  status: 'learning',
});

Raw SQL with sql Tag

import { sql } from 'drizzle-orm';

// Raw SQL expression in select
const result = await db.select({
  id: users.id,
  daysSinceCreation: sql<number>`EXTRACT(day FROM NOW() - ${users.createdAt})`,
  nameUpper: sql<string>`UPPER(${users.name})`,
}).from(users);

// Raw WHERE clause
const recent = await db.select()
  .from(users)
  .where(sql`${users.createdAt} > NOW() - INTERVAL '30 days'`);

// Full raw query (escape hatch)
const rawResult = await db.execute(
  sql`SELECT * FROM users WHERE id = ${userId}`
);

JSON/JSONB Columns with Type Safety

// Type your jsonb columns
type PageMetadata = {
  wordCount: number;
  lastEdited: string;
  aiGeneratedAt?: string;
};

export const pages = pgTable('pages', {
  // ...
  metadata: jsonb('metadata').$type<PageMetadata>(),
  // Now metadata is typed as PageMetadata | null
});

// Query jsonb field
const recentlyEdited = await db.select()
  .from(pages)
  .where(sql`${pages.metadata}->>'lastEdited' > ${cutoff}`);

// Update jsonb field
await db.update(pages)
  .set({ metadata: { wordCount: 500, lastEdited: new Date().toISOString() } })
  .where(eq(pages.id, pageId));

Subqueries & CTEs

// Subquery
const subquery = db.select({ userId: folders.userId })
  .from(folders)
  .where(eq(folders.id, folderId))
  .as('subq');

const user = await db.select()
  .from(users)
  .where(eq(users.id, sql`(${subquery})`));

// CTE (Common Table Expression) with $with
const latestPages = db.$with('latest_pages').as(
  db.select({
    folderId: pages.folderId,
    maxUpdated: sql<Date>`MAX(${pages.updatedAt})`.as('max_updated'),
  })
    .from(pages)
    .groupBy(pages.folderId)
);

const result = await db.with(latestPages)
  .select({
    folder: folders,
    lastUpdated: latestPages.maxUpdated,
  })
  .from(folders)
  .leftJoin(latestPages, eq(latestPages.folderId, folders.id));

Performance Tips

  • Use .limit() everywhere — never fetch unbounded datasets

  • Select only needed columns: .select({ id: users.id, name: users.name }) avoids fetching large columns (content, metadata)

  • Add indexes for foreign keys and frequently queried columns via index() in table definition

  • Prepared statements for repeated queries — parse plan is cached in PostgreSQL

  • Use db.batch() to reduce round-trips (Neon HTTP or SQLite)

  • Prefer relational API (db.query) for joins — Drizzle optimizes the SQL. Manual joins for complex aggregates.

  • Use connection pooling in long-running servers (PgBouncer, Neon pooled endpoint, RDS Proxy)

  • For large bulk operations: use db.insert().values(manyRows) — single SQL statement is much faster than loop

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free