Skip to content

Drizzle — Pitfalls

OWNER: urszula, maxim ALSO_USED_BY: boris, floris, hugo, jasper, marco, stef, arjan LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: drizzle-orm 0.45.x


Overview

Known failure modes, gotchas, and traps that GE agents have encountered with Drizzle ORM. Every item here was discovered in production or during code review. Read before writing any Drizzle code.


Upsert: onConflictDoUpdate Gotchas

The set clause must repeat values

ANTI_PATTERN: Expecting onConflictDoUpdate to reuse values from the values() clause automatically.

// BROKEN — set is empty, update does nothing
await db.insert(agents)
  .values({ id: 'urszula', name: 'Urszula', role: 'Dev' })
  .onConflictDoUpdate({
    target: agents.id,
    set: {},  // Oops — nothing gets updated
  });

FIX: Explicitly list every field that should be updated in set:

await db.insert(agents)
  .values({ id: 'urszula', name: 'Urszula', role: 'Dev', syncedAt: new Date() })
  .onConflictDoUpdate({
    target: agents.id,
    set: {
      name: 'Urszula',
      role: 'Dev',
      syncedAt: new Date(),
    },
  });

Using excluded values

To reference the would-be-inserted values in the set clause:

import { sql } from 'drizzle-orm';

await db.insert(agents)
  .values({ id: 'urszula', name: 'Urszula', syncedAt: new Date() })
  .onConflictDoUpdate({
    target: agents.id,
    set: {
      name: sql`excluded.name`,
      syncedAt: sql`excluded.synced_at`,
    },
  });

CHECK: Agent is using excluded references in onConflictDoUpdate. IF: The column name in excluded.xxx is camelCase. THEN: STOP. Use the snake_case SQL column name: excluded.synced_at not excluded.syncedAt.


Select-Then-Insert Race Condition

ANTI_PATTERN: Checking existence before inserting.

// BROKEN — race condition under concurrent requests
const existing = await db.select().from(agents).where(eq(agents.id, id));
if (existing.length === 0) {
  await db.insert(agents).values({ id, name, role });
} else {
  await db.update(agents).set({ name, role }).where(eq(agents.id, id));
}

FIX: Use onConflictDoUpdate — atomic at the database level:

await db.insert(agents)
  .values({ id, name, role })
  .onConflictDoUpdate({
    target: agents.id,
    set: { name, role },
  });

This is a GE HARD RULE. No exceptions.


Relational Query Silent Failures

ANTI_PATTERN: Using with: in relational queries without defined relations.

// SILENT FAILURE — returns learnings: [] even if data exists
const agent = await db.query.agents.findFirst({
  where: eq(agents.id, 'urszula'),
  with: { learnings: true },
});

FIX: Define relations in the schema:

export const agentsRelations = relations(agents, ({ many }) => ({
  learnings: many(learnings),
}));

CHECK: Agent is using db.query.*.findMany() or findFirst() with with:. IF: The result has empty nested arrays where data should exist. THEN: Check that relations() are defined for the table.


N+1 in Relational Queries

ANTI_PATTERN: Using relational API in a loop.

// N+1 — one query per agent
const agents = await db.query.agents.findMany();
for (const agent of agents) {
  const tasks = await db.query.tasks.findMany({
    where: eq(tasks.agentId, agent.id),
  });
}

FIX: Use with: for nested loading, or a single join query:

// Option 1: Relational API (issues 2 queries, not N+1)
const agents = await db.query.agents.findMany({
  with: { tasks: true },
});

// Option 2: Query builder join (single query)
const result = await db
  .select()
  .from(agents)
  .leftJoin(tasks, eq(agents.id, tasks.agentId));


Type Inference Issues

Inferred types from select

ANTI_PATTERN: Manually typing query results.

// FRAGILE — type gets stale when schema changes
interface Agent {
  id: string;
  name: string;
  role: string;
}
const agents: Agent[] = await db.select().from(agentsTable);

FIX: Use Drizzle's inferred types:

import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';

type Agent = InferSelectModel<typeof agents>;
type NewAgent = InferInsertModel<typeof agents>;

JSONB type narrowing

ANTI_PATTERN: Accessing JSONB fields without type guards.

// RUNTIME ERROR — skills might be null
const firstSkill = agent.skills[0];

FIX: Always check JSONB nullability:

const firstSkill = agent.skills?.[0] ?? 'none';


Migration Ordering Issues

ANTI_PATTERN: Two agents generating migrations simultaneously. Both get 0008_*.sql — drizzle-kit fails on duplicate sequence numbers.

FIX: Migration generation is serialized. Only ONE agent generates migrations at a time. Coordinate via the task system.

ANTI_PATTERN: Renaming a migration file without updating _journal.json.

# The journal references the old filename — migration runner fails

FIX: When renaming a migration file, update the tag field in drizzle/migrations/meta/_journal.json to match.


Zod v4 Integration

GE uses Zod v4 for runtime validation. When validating Drizzle types with Zod:

ANTI_PATTERN: Using .errors on ZodError (Zod v3 API).

try {
  schema.parse(data);
} catch (e) {
  if (e instanceof ZodError) {
    console.log(e.errors);  // BROKEN in Zod v4
  }
}

FIX: Use .issues — the Zod v4 API:

try {
  schema.parse(data);
} catch (e) {
  if (e instanceof ZodError) {
    console.log(e.issues);  // CORRECT for Zod v4
  }
}

CHECK: Agent is catching ZodError. IF: Code references .errors property. THEN: Change to .issues. This is a GE-wide Zod v4 rule.


Timestamp Timezone Trap

ANTI_PATTERN: Defining timestamps without timezone.

createdAt: timestamp('created_at').defaultNow(),

FIX: Always use withTimezone: true:

createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),

Without withTimezone: true, PostgreSQL stores timestamp without time zone. This silently drops timezone information and causes bugs when the app server and database are in different timezones (which they will be — k3s nodes vs EU cloud).


Multiple Postgres Clients

ANTI_PATTERN: Creating postgres() client in multiple files.

// lib/db/index.ts
const client = postgres(url);

// lib/services/some-service.ts
const client2 = postgres(url);  // Second connection pool!

FIX: Single postgres() client in lib/db/index.ts, imported everywhere. Each postgres() call creates a new connection pool. Multiple pools exhaust PostgreSQL's max_connections. READ_ALSO: wiki/docs/stack/postgresql/performance.md (connection pooling)


drizzle-kit push in Production

ANTI_PATTERN: Running drizzle-kit push against staging or production. Push applies changes without generating migration files. There is NO audit trail and NO rollback path.

FIX: Use drizzle-kit generate + drizzle-kit migrate for any environment beyond local development. READ_ALSO: wiki/docs/stack/drizzle/migrations.md


Empty .returning() Destructuring

ANTI_PATTERN: Destructuring without checking emptiness.

const [created] = await db.insert(tasks).values(data).returning();
// If insert fails silently, created is undefined

FIX: Check the result:

const result = await db.insert(tasks).values(data).returning();
if (result.length === 0) {
  throw new Error('Insert failed — no rows returned');
}
const created = result[0];


Enum Updates Require Migration

ANTI_PATTERN: Adding a value to pgEnum in schema and running push. In some drizzle-kit versions, enum changes are not detected by push.

FIX: Write a manual migration:

ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'paused';

Then update the schema to match.


Cross-References

READ_ALSO: wiki/docs/stack/drizzle/index.md READ_ALSO: wiki/docs/stack/drizzle/queries.md READ_ALSO: wiki/docs/stack/drizzle/migrations.md READ_ALSO: wiki/docs/stack/drizzle/checklist.md READ_ALSO: wiki/docs/stack/postgresql/pitfalls.md