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:
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.
FIX: Always check JSONB nullability:
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.
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.
FIX: Always use withTimezone: true:
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:
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