Skip to content

DOMAIN:BACKEND:DRIZZLE_ORM

OWNER: urszula (Team Alfa), maxim (Team Bravo)
UPDATED: 2026-03-24
SCOPE: Drizzle ORM patterns for all GE client projects
VERSION: Drizzle ORM 0.38+ / Drizzle Kit 0.30+
DATABASE: PostgreSQL 15+


DRIZZLE:WHY_DRIZZLE

WHY_NOT_PRISMA:
- Prisma uses custom .prisma schema language — not TypeScript
- Prisma generates a heavy runtime client (~2MB)
- Prisma's query engine is a Rust binary — complicates deployment
- Prisma's type inference is slow for large schemas

WHY_DRIZZLE:
- Pure TypeScript schema — use functions, variables, generics
- Thin SQL wrapper — ~7.4KB, no runtime code generation
- Types inferred directly from schema — zero codegen step
- SQL-like query builder — if you know SQL, you know Drizzle
- Relational query API for typed nested joins
- Serverless/edge-friendly — no binary dependencies


DRIZZLE:SCHEMA_DEFINITION

TABLE_DEFINITION

// db/schema/users.ts
import { pgTable, text, timestamp, boolean, integer, uuid, index } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  role: text('role', { enum: ['admin', 'user', 'viewer'] }).notNull().default('user'),
  isActive: boolean('is_active').notNull().default(true),
  loginCount: integer('login_count').notNull().default(0),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  index('users_email_idx').on(table.email),
  index('users_created_at_idx').on(table.createdAt),
]);

RULE: use uuid for primary keys with .defaultRandom() — never auto-increment serial
RULE: use text with enum option for string enums — type-safe, no separate enum table
RULE: always include createdAt and updatedAt timestamps with timezone
RULE: define indexes in the table callback — not as separate statements

REUSABLE_COLUMN_PATTERNS

// db/schema/_columns.ts
import { timestamp, uuid } from 'drizzle-orm/pg-core';

export const timestamps = {
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
};

export const softDelete = {
  deletedAt: timestamp('deleted_at', { withTimezone: true }),
};

export const primaryId = {
  id: uuid('id').defaultRandom().primaryKey(),
};

// Usage
export const projects = pgTable('projects', {
  ...primaryId,
  name: text('name').notNull(),
  clientId: uuid('client_id').notNull().references(() => clients.id),
  ...timestamps,
  ...softDelete,
});

RELATIONS

// db/schema/relations.ts
import { relations } from 'drizzle-orm';
import { users } from './users';
import { projects } from './projects';
import { tasks } from './tasks';

export const usersRelations = relations(users, ({ many }) => ({
  projects: many(projects),
}));

export const projectsRelations = relations(projects, ({ one, many }) => ({
  owner: one(users, {
    fields: [projects.ownerId],
    references: [users.id],
  }),
  tasks: many(tasks),
}));

export const tasksRelations = relations(tasks, ({ one }) => ({
  project: one(projects, {
    fields: [tasks.projectId],
    references: [projects.id],
  }),
  assignee: one(users, {
    fields: [tasks.assigneeId],
    references: [users.id],
  }),
}));

TYPE_INFERENCE

// db/schema/types.ts
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
import { users } from './users';

export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;

// For partial updates
export type UserUpdate = Partial<Omit<NewUser, 'id' | 'createdAt'>>;

DRIZZLE:MIGRATIONS

WORKFLOW

DEVELOPMENT:
1. Edit schema TypeScript files
2. Run: npx drizzle-kit generate
3. Review generated SQL in drizzle/ directory
4. Run: npx drizzle-kit migrate (or migrate() in code)
5. Commit schema + migration files together

PRODUCTION:
1. Run migrations via migrate() function during app startup
2. NEVER use drizzle-kit push in production
3. ALWAYS review generated SQL before deploying

DRIZZLE_CONFIG

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

export default defineConfig({
  schema: './db/schema/*.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

PROGRAMMATIC_MIGRATION

// db/migrate.ts
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { db } from './connection';

export async function runMigrations() {
  console.log('Running migrations...');
  await migrate(db, { migrationsFolder: './drizzle/migrations' });
  console.log('Migrations complete');
}

ZERO_DOWNTIME_MIGRATIONS

RULE: never rename or retype a column in a single step
RULE: use expand-contract pattern across multiple deploys
RULE: always create indexes CONCURRENTLY in production

EXPAND_CONTRACT_PATTERN (example: rename column "name" to "full_name"):

DEPLOY_1 (expand):
  1. Add new column "full_name"
  2. Backfill: UPDATE users SET full_name = name WHERE full_name IS NULL
  3. Application writes to BOTH name and full_name

DEPLOY_2 (migrate):
  1. Application reads from full_name, writes to both
  2. Verify all rows have full_name populated

DEPLOY_3 (contract):
  1. Application reads/writes only full_name
  2. Drop old column "name"

SAFE_INDEX_CREATION

-- In migration file, add CONCURRENTLY manually
-- drizzle-kit does NOT add CONCURRENTLY by default
CREATE INDEX CONCURRENTLY IF NOT EXISTS "users_email_idx" ON "users" ("email");

ANTI_PATTERN: drizzle-kit push in production — no migration files, no audit trail, no rollback
FIX: always drizzle-kit generate then drizzle-kit migrate

ANTI_PATTERN: trusting generated SQL without review — Drizzle may generate destructive DDL
FIX: always read the generated .sql file before deploying

ANTI_PATTERN: running migrations in every pod startup in multi-replica deployments
FIX: run migrations as a separate init container or one-time job


DRIZZLE:QUERY_PATTERNS

SELECT_QUERIES

// Simple select
const user = await db.query.users.findFirst({
  where: eq(users.id, userId),
});

// Select with conditions
const activeAdmins = await db.select()
  .from(users)
  .where(and(
    eq(users.role, 'admin'),
    eq(users.isActive, true),
  ));

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

RELATIONAL_QUERIES (AVOIDING N+1)

// BAD: N+1 query pattern
const projects = await db.select().from(projects);
for (const project of projects) {
  // This runs a query PER project — N+1
  project.tasks = await db.select().from(tasks).where(eq(tasks.projectId, project.id));
}

// GOOD: Relational query — single query with join
const projectsWithTasks = await db.query.projects.findMany({
  with: {
    tasks: true,
    owner: {
      columns: { id: true, name: true, email: true },
    },
  },
  where: eq(projects.isActive, true),
});

// GOOD: Explicit join when you need more control
const results = await db.select({
  project: projects,
  taskCount: count(tasks.id),
}).from(projects)
  .leftJoin(tasks, eq(projects.id, tasks.projectId))
  .groupBy(projects.id);

INSERT_PATTERNS

// Single insert with return
const [newUser] = await db.insert(users).values({
  email: 'alice@example.com',
  name: 'Alice',
}).returning();

// Bulk insert
await db.insert(tasks).values([
  { title: 'Task 1', projectId },
  { title: 'Task 2', projectId },
  { title: 'Task 3', projectId },
]);

// Upsert (insert or update on conflict)
await db.insert(users)
  .values({ email: 'alice@example.com', name: 'Alice Updated' })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: 'Alice Updated', updatedAt: new Date() },
  });

RULE: always use onConflictDoUpdate for upserts — never select-then-insert (race condition)

UPDATE_AND_DELETE

// Update with returning
const [updated] = await db.update(users)
  .set({ name: 'New Name', updatedAt: new Date() })
  .where(eq(users.id, userId))
  .returning();

// Soft delete
await db.update(users)
  .set({ deletedAt: new Date(), isActive: false })
  .where(eq(users.id, userId));

DRIZZLE:QUERY_BUILDER_VS_RAW_SQL

CHECK: can Drizzle's query builder express this query?
IF: yes THEN: use query builder — type-safe, composable
IF: no (window functions, CTEs, complex aggregations) THEN: use sql`` tagged template

// Raw SQL with Drizzle's sql`` tag — still parameterized, still safe
import { sql } from 'drizzle-orm';

const result = await db.execute(sql`
  WITH monthly_stats AS (
    SELECT
      date_trunc('month', created_at) AS month,
      COUNT(*) AS task_count,
      AVG(EXTRACT(EPOCH FROM (completed_at - created_at))) AS avg_duration_sec
    FROM tasks
    WHERE project_id = ${projectId}
    GROUP BY date_trunc('month', created_at)
  )
  SELECT * FROM monthly_stats
  ORDER BY month DESC
  LIMIT 12
`);

RULE: sql` template literals are parameterized — safe from SQL injection ANTI_PATTERN:sql.raw(userInput)` — bypasses parameterization, allows injection
FIX: never use sql.raw() with any user-provided value


DRIZZLE:TRANSACTIONS

// Basic transaction
const result = await db.transaction(async (tx) => {
  const [project] = await tx.insert(projects).values({
    name: 'New Project',
    ownerId: userId,
  }).returning();

  await tx.insert(tasks).values({
    title: 'Initial task',
    projectId: project.id,
  });

  return project;
});

// Transaction with rollback
await db.transaction(async (tx) => {
  await tx.update(accounts)
    .set({ balance: sql`balance - ${amount}` })
    .where(eq(accounts.id, fromAccountId));

  const [fromAccount] = await tx.select()
    .from(accounts)
    .where(eq(accounts.id, fromAccountId));

  if (fromAccount.balance < 0) {
    tx.rollback();  // Throws, rolls back transaction
  }

  await tx.update(accounts)
    .set({ balance: sql`balance + ${amount}` })
    .where(eq(accounts.id, toAccountId));
});

RULE: keep transactions short — long transactions hold locks
RULE: never await external APIs inside transactions (network calls)
RULE: always handle tx.rollback() — it throws, catch it if needed


DRIZZLE:CONNECTION_POOLING

WITH_POSTGRES_JS

// db/connection.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const connectionString = process.env.DATABASE_URL!;

// Connection pool configuration
const sql = postgres(connectionString, {
  max: 20,                    // max connections in pool
  idle_timeout: 20,           // close idle connections after 20s
  connect_timeout: 10,        // timeout for new connections
  max_lifetime: 60 * 30,     // max connection lifetime 30min
  prepare: true,              // use prepared statements (faster)
});

export const db = drizzle(sql, { schema });

// Graceful shutdown — drain pool
export async function closeDatabase() {
  await sql.end();
}

POOL_SIZING_GUIDE

FORMULA: pool_size = (cpu_cores * 2) + effective_spindle_count
TYPICAL: 4 cores = ~10 connections
RULE: for most GE projects, 10-20 connections is appropriate
NEVER: set pool > 50 — PostgreSQL has diminishing returns beyond this
SERVERLESS: use connection pooler (PgBouncer or Neon) — set max to 5

ANTI_PATTERN: creating a new connection per request (no pooling)
FIX: use postgres() pool once at app startup, reuse via db instance

ANTI_PATTERN: pool size of 100+ — causes PostgreSQL contention
FIX: 10-20 connections for typical workloads, use EXPLAIN ANALYZE to tune


DRIZZLE:INDEXING

// In table definition
export const tasks = pgTable('tasks', {
  id: uuid('id').defaultRandom().primaryKey(),
  projectId: uuid('project_id').notNull().references(() => projects.id),
  assigneeId: uuid('assignee_id').references(() => users.id),
  status: text('status', { enum: ['todo', 'in_progress', 'done'] }).notNull().default('todo'),
  priority: integer('priority').notNull().default(0),
  title: text('title').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  // Index on foreign keys — always
  index('tasks_project_id_idx').on(table.projectId),
  index('tasks_assignee_id_idx').on(table.assigneeId),
  // Composite index for common query pattern
  index('tasks_project_status_idx').on(table.projectId, table.status),
  // Partial index for active tasks
  index('tasks_active_idx').on(table.status).where(sql`status != 'done'`),
]);

RULE: always index foreign key columns
RULE: composite indexes — put most selective column first, or match query filter order
RULE: use jsonb() not json() for JSON columns — jsonb is indexed and faster
RULE: use partial indexes for common WHERE conditions (e.g., WHERE status = 'active')


DRIZZLE:COMMON_PITFALLS

ANTI_PATTERN: N+1 queries in loops
FIX: use relational queries db.query.table.findMany({ with: { relation: true } })

ANTI_PATTERN: missing indexes on WHERE/JOIN columns
FIX: check EXPLAIN ANALYZE output — sequential scan on large tables = missing index
TOOL: EXPLAIN ANALYZE SELECT * FROM tasks WHERE project_id = 'uuid'

ANTI_PATTERN: not using onConflictDoUpdate for upserts
FIX: db.insert().values().onConflictDoUpdate() — atomic, race-condition-free

ANTI_PATTERN: putting business logic inside schema files
FIX: schema files define structure only — business logic goes in service files

ANTI_PATTERN: using .default(sqlnow()) without { withTimezone: true }
FIX: always use timestamp('col', { withTimezone: true }) — avoids timezone bugs

ANTI_PATTERN: forgetting to update updatedAt on UPDATE queries
FIX: always include updatedAt: new Date() in .set() calls, or use database trigger

ANTI_PATTERN: selecting all columns when only a few are needed
FIX: use db.select({ id: users.id, name: users.name }) for read-heavy queries


DRIZZLE:AGENTIC_CHECKLIST

ON_WRITING_DATABASE_CODE:
1. CHECK: is schema defined with proper types, indexes, and relations?
2. CHECK: are foreign keys indexed?
3. CHECK: are queries using relational API for joins (not loops)?
4. CHECK: are upserts using onConflictDoUpdate?
5. CHECK: are transactions used for multi-step writes?
6. CHECK: is connection pool configured (not creating connections per request)?
7. CHECK: does migration SQL look safe? (review before applying)
8. CHECK: are timestamps using withTimezone: true?
9. RUN: vitest to verify query results match expected shapes
10. IF: new table or column THEN: generate migration with drizzle-kit