Claude Code Drizzle ORM CLAUDE.md TypeScript PostgreSQL AI Coding 2026

Claude Code for Drizzle ORM: CLAUDE.md Rules for Driver Drift, the db:push Trap, and Relational Queries (2026)

The Prompt Shelf ·

Drizzle ships one query API surface that sits on top of more than half a dozen driver adapters — node-postgres, postgres-js, neon-http, neon-serverless, d1, bun-sql, libsql — and the imports, connection setup, and transaction guarantees differ by driver even though the db.select() calls that follow look identical. A CLAUDE.md that doesn’t say which driver a project actually uses gets code written against the wrong one, and the failure doesn’t show up until a connection string that was never supposed to exist throws at runtime.

This guide covers a complete CLAUDE.md template for Drizzle — the driver selection matrix most guides skip, why drizzle-kit push is safe in development and dangerous in production, the relational query API’s nested-object shape versus a manual leftJoin’s flat-namespaced shape, and $inferSelect/$inferInsert for keeping application types in sync with the schema without hand-written duplicates. Real Drizzle CLAUDE.md/AGENTS.md examples are browsable in our gallery.

Why Drizzle Needs Its Own CLAUDE.md Section

Drizzle’s pitch is being close to SQL — a thin, typed layer with no code-generation step and no separate schema DSL to learn. That’s exactly what makes it easy for an agent to get subtly wrong: there’s no Prisma-style generate step that fails loudly when something’s inconsistent, and TypeScript will happily compile code written against the wrong driver’s import path until it’s actually run.

Out of the box, Claude Code working on a Drizzle codebase will:

  • Import drizzle-orm/node-postgres (the most common tutorial driver) on a project that’s actually deployed against Cloudflare D1 or Neon’s HTTP endpoint, because the driver isn’t stated anywhere
  • Reach for drizzle-kit push because it’s the fastest path to “the schema change worked,” even on a project with a committed drizzle/migrations/ directory
  • Write db.transaction(async (tx) => { ... }) against a driver that doesn’t support interactive transactions, producing code that works in local Postgres testing and fails against the production HTTP endpoint
  • Add a foreign key column without a matching relations() block, so db.query.posts.findMany({ with: { author: true } }) silently returns undefined for author instead of throwing
  • Mix the relational query API’s nested result shape with a manual join’s flat-namespaced shape in the same codebase, because both look like “just querying the database”

None of this is a Drizzle weakness — the library is deliberately unopinionated about which driver and which query style a project uses, which means the specific choice has to live in CLAUDE.md instead of being something the tool enforces.

Complete CLAUDE.md Template for Drizzle ORM Projects

# [ProjectName] — Drizzle ORM

## Database & Driver
- Database: [PostgreSQL / MySQL / SQLite / Cloudflare D1]
- Driver: `drizzle-orm/[node-postgres|postgres-js|neon-http|neon-serverless|d1|bun-sql|libsql]`
- Connection: [describe how the connection is established — pool, HTTP client,
  binding — see Driver Selection section below]
- Do NOT import a different driver adapter than the one above, even if it
  looks equivalent. The query API is the same; the connection and transaction
  behavior are not.

## Commands
- Generate migration from schema changes: `npx drizzle-kit generate`
- Apply migrations (all environments): `npx drizzle-kit migrate`
- Push schema directly (LOCAL DEV ONLY — never on this project's staging/prod): `npx drizzle-kit push`
- Open Drizzle Studio: `npx drizzle-kit studio`
- Type check: `tsc --noEmit`

## Schema Conventions
- Tables: snake_case (`user_accounts`, not `UserAccounts`)
- Columns: snake_case in the schema definition, Drizzle exposes them as the
  same key in query results (no separate DB-name/app-name mapping like
  Prisma's @map)
- Every table's primary key and every foreign key column gets an explicit
  index unless the column is already covered by a composite index
- Every foreign key column MUST have a matching `relations()` block in the
  same file — see Relational Queries section

## Migrations
- `drizzle-kit generate` locally after every schema.ts change, then review the
  generated SQL in `drizzle/migrations/` before committing
- `drizzle-kit migrate` in CI/CD to apply committed migrations — never `push`
- `drizzle-kit push` is permitted only against the local dev database. Do not
  run it against any database whose connection string isn't the local one.
- Never hand-edit a migration file after it has been committed and applied.
  Generate a new migration instead.
- Show me the generated SQL before considering a schema-change task done —
  a column rename generates DROP + ADD by default (see below) unless it's
  interactively resolved as a rename during `generate`.

## Relational Queries vs Manual Joins
- Prefer `db.query.<table>.findMany({ with: { ... } })` for reads that need
  related data — it returns plain nested objects (`post.author.name`).
- Use `db.select().from().leftJoin()` only when you need an aggregate, a
  computed column, or fields chosen with `.select({ ... })` that don't map
  cleanly to full-table `with` includes.
- Never mix shapes in the same function: a join result is namespaced by
  table (`row.users.name`, `row.posts.title`), a relational query result
  is not (`row.author.name` directly).

## Type Inference
- Never hand-write a type that duplicates a table's shape. Use
  `typeof table.$inferSelect` for read shapes and
  `typeof table.$inferInsert` for write shapes — they stay in sync with
  schema.ts automatically.
- For runtime validation (API request bodies, form data), generate the
  schema with `drizzle-zod`'s `createInsertSchema`/`createSelectSchema`
  rather than writing a parallel Zod schema by hand.

## Transactions
- [Driver supports interactive transactions: node-postgres / postgres-js /
  neon-serverless / bun-sql / libsql] — multi-step writes that must succeed
  or fail together use `db.transaction(async (tx) => { ... })`, always
  calling `tx.*`, never the outer `db.*`, inside the callback.
- [Driver is HTTP-only: neon-http / d1] — this driver does NOT support
  interactive transactions with mid-transaction branching. Multi-statement
  atomicity here is limited to `db.batch([...])`. Do not write
  `db.transaction()` code that depends on reading a value and branching
  before the next write — restructure as a single batched statement set or
  move the branch to application logic before the batch.

## What NOT to Do
- Do not import a driver adapter other than the one declared above.
- Do not run `drizzle-kit push` against anything but the local dev database.
- Do not add a foreign key column without a matching `relations()` block.
- Do not mix relational-query result shapes with join result shapes in the
  same function.
- Do not hand-write types that duplicate `$inferSelect`/`$inferInsert`.
- Do not write `db.transaction()` on an HTTP-only driver expecting
  interactive rollback semantics.

Driver Selection: Same API, Different Connection Model

This is the section most Drizzle guides skip entirely, and it’s the one that causes runtime failures instead of code review comments — every driver exposes the same db.select()/db.insert() surface, so nothing in the code itself signals a mismatch until it’s run against a real connection.

## Driver Reference

| Driver import                    | Target                          | Connection model      | Interactive transactions |
|-----------------------------------|----------------------------------|------------------------|---------------------------|
| `drizzle-orm/node-postgres`       | Any Postgres, self-hosted/RDS    | TCP pool (`pg.Pool`)   | Yes                       |
| `drizzle-orm/postgres-js`         | Any Postgres, incl. Supabase     | TCP pool               | Yes                       |
| `drizzle-orm/neon-serverless`     | Neon (websocket)                 | Session over websocket | Yes                       |
| `drizzle-orm/neon-http`           | Neon (HTTP endpoint, edge-safe)  | Stateless HTTP         | Batch only, not interactive |
| `drizzle-orm/d1`                  | Cloudflare D1                    | Binding (`env.DB`)     | Batch only, not interactive |
| `drizzle-orm/bun-sql`             | Any Postgres, Bun runtime        | Native Bun TCP client  | Yes                       |
| `drizzle-orm/libsql`              | Turso / libSQL                   | HTTP or embedded       | Yes                       |

This project uses: **[fill in]**. Do not import from any other row of this
table, even to "try something."

Two connection setups, to make the difference concrete:

// node-postgres — traditional long-running server, full TCP pool
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
// neon-http — edge/serverless, stateless HTTP, no persistent connection
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

The two db objects expose the same .select()/.query.* API, which is exactly the trap: code copy-pasted from a node-postgres project into a Workers project deployed against neon-http type-checks and often runs fine for simple reads — until it hits a db.transaction() call that the HTTP driver can’t fulfill the way the code assumes.

The drizzle-kit push Production Trap

drizzle-kit push reads your schema.ts, diffs it against the live database’s actual structure, and applies the difference immediately — no migration file, no confirmation, no history. It exists for the specific case where iteration speed matters more than a reviewable trail: local development, throwaway prototypes, early-stage schemas nobody’s built on yet.

The trap is that it works identically well against a production database, with no warning that it’s about to run there:

## Migrations — push vs generate + migrate

`drizzle-kit push` — local development only. Diffs schema.ts against the
live DB and applies changes immediately. No migration file, no history,
can silently DROP a column if a rename wasn't declared as one.

`drizzle-kit generate` + `drizzle-kit migrate` — every environment that
isn't the local dev database. generate writes a reviewable SQL file to
drizzle/migrations/; migrate applies committed files in order.

This project has a committed drizzle/migrations/ directory. Never run
`drizzle-kit push` against DATABASE_URL pointing at staging or production —
only against the connection string in .env.local.

The column-rename case is worth stating explicitly, because it’s the one that loses data silently: if a field is renamed in schema.ts, both push and generate can produce a DROP COLUMN old_name + ADD COLUMN new_name instead of recognizing it as a rename — generate at least asks interactively during the CLI run whether a change is a rename, which push does not surface the same way when run non-interactively in a script. Telling Claude Code to always show the generated SQL, and to answer the rename prompt correctly instead of accepting the drop-and-add default, is the difference between a routine migration and a data-loss incident.

Relational Queries vs Manual Joins: Two Different Result Shapes

Drizzle offers two ways to fetch related data, and they return meaningfully different shapes — a detail that’s easy to skip past in the docs because both “just work” for a simple case.

The relational query API (db.query.*) returns normal nested objects:

const post = await db.query.posts.findFirst({
  where: eq(posts.id, postId),
  with: { author: true, comments: { with: { author: true } } },
});

post.author.name; // direct property access
post.comments[0].author.name; // nested the way you'd expect from JSON

A manual join (db.select().from().leftJoin()) returns rows namespaced by table:

const rows = await db
  .select()
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id));

rows[0].posts.title; // not rows[0].title
rows[0].users.name; // not rows[0].name — and every users.* field is null
                     // if the join found no match, not the whole `users`
                     // property being undefined

For the relational query API to work at all, the relationship has to be declared separately from the foreign key column — the FK enables referential integrity at the database level, relations() enables the query API to know the relationship exists:

// schema.ts
export const posts = pgTable("posts", {
  id: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  authorId: uuid("author_id").references(() => users.id),
});

// this block is what makes `with: { author: true }` work —
// it is not inferred from the .references() call above
export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
## Relational Queries
Every foreign key column gets a matching `relations()` block in the same
schema file, defined immediately after the table it belongs to. A foreign
key without a `relations()` entry compiles fine and fails silently at query
time — `with: { author: true }` returns `undefined` instead of throwing,
which is easy to miss in a quick manual test and easy to ship.

Type Inference: $inferSelect and $inferInsert

Drizzle’s schema is the single source of truth for types — writing a parallel interface Post { ... } next to schema.ts guarantees the two drift apart the first time a column is added and someone forgets the second file.

export const posts = pgTable("posts", {
  id: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  published: boolean("published").default(false),
  authorId: uuid("author_id").references(() => users.id),
});

// read shape — matches exactly what a SELECT returns
export type Post = typeof posts.$inferSelect;

// write shape — omits defaulted/auto-generated columns as optional
export type NewPost = typeof posts.$inferInsert;

For runtime validation at the API boundary, drizzle-zod generates a matching Zod schema from the same table definition instead of a third hand-written copy:

import { createInsertSchema, createSelectSchema } from "drizzle-zod";

const insertPostSchema = createInsertSchema(posts, {
  title: (schema) => schema.min(1).max(200), // refine specific fields
});
const selectPostSchema = createSelectSchema(posts);
## Types
Never hand-write a type that duplicates a table shape. Use
`typeof table.$inferSelect` / `typeof table.$inferInsert`. For request-body
validation, generate the Zod schema with `createInsertSchema` from
drizzle-zod, refining only the fields that need stricter rules than the
column type implies (e.g. a string length cap `text` alone doesn't enforce).

Transactions

## Transactions
[If driver supports interactive transactions — node-postgres, postgres-js,
neon-serverless, bun-sql, libsql:]
Multi-step writes that must succeed or fail together use
`db.transaction(async (tx) => { ... })`. Inside the callback, always use
`tx`, never the outer `db` — a query run against `db` inside a transaction
callback executes outside the transaction silently.

[If driver is HTTP-only — neon-http, d1:]
This driver does not support interactive transactions. Atomic multi-statement
writes use `db.batch([...])`, which executes a fixed, pre-built list of
statements atomically but cannot branch mid-transaction based on an
intermediate query's result. If a write genuinely needs read-then-branch
logic inside one atomic unit, that's a signal this operation needs a
TCP/websocket driver, not a workaround on the HTTP driver.
// interactive transaction — TCP/websocket drivers only
await db.transaction(async (tx) => {
  const account = await tx.query.accounts.findFirst({ where: eq(accounts.id, id) });
  if (!account || account.balance < amount) {
    throw new Error("Insufficient balance"); // rolls back the whole transaction
  }
  await tx.update(accounts).set({ balance: account.balance - amount }).where(eq(accounts.id, id));
});
// batch — HTTP-only drivers, no mid-batch branching
await db.batch([
  db.insert(orders).values(orderData),
  db.update(inventory).set({ stock: sql`${inventory.stock} - 1` }).where(eq(inventory.id, itemId)),
]);

AGENTS.md Version for Cross-Tool Teams

# [ProjectName] — AI Agent Context

## Database
Drizzle ORM, driver: drizzle-orm/[driver]. [Postgres/MySQL/SQLite/D1].

## Migrations
generate + migrate everywhere except local dev. push is local-dev only,
never against staging/prod connection strings.

## Relational Queries
db.query.* returns nested objects (post.author.name). select().leftJoin()
returns table-namespaced rows (row.posts.title, row.users.name). Do not mix
shapes in one function. Every FK column needs a matching relations() block.

## Types
typeof table.$inferSelect / $inferInsert — never hand-written duplicates.
drizzle-zod for request validation schemas.

## Transactions
[State whether this project's driver supports db.transaction() or is
batch-only — see Transactions section above.]

Common Mistakes We See in Drizzle + Claude Code Projects

Wrong driver import copied from a different project’s example. The single highest-frequency mistake — node-postgres code pasted into a Workers/D1 project, or neon-http code pasted into a long-running-server project. Both compile; only one connects.

drizzle-kit push run against DATABASE_URL that happened to point at staging. Push doesn’t check what it’s connected to — it applies the diff to whatever connection string it’s given. If the local .env and the staging .env use the same variable name, an agent running push from muscle memory doesn’t know it just pushed to staging.

relations() block forgotten after adding a foreign key. The foreign key alone is enough for the database to enforce referential integrity, so the schema “looks complete” without it. The relational query API failing silently (returning undefined instead of throwing) makes this easy to miss until a feature that depends on the joined data is already in review.

db.transaction() written against an HTTP-only driver. Works in local testing against a TCP-based Postgres instance during development, then fails or behaves unexpectedly once deployed against the actual neon-http or d1 connection used in production.

Mixed result shapes in the same query function. A function that starts with db.query.posts.findMany({ with: { author: true } }) and later needs one aggregate column gets rewritten as a manual join, and the rest of the function’s post.author.name accesses don’t get updated to row.users.name — this fails at runtime, not at compile time, because both shapes are valid objects.

Frequently Asked Questions

Why does Claude Code import the wrong Drizzle driver?

Drizzle’s driver adapters share nearly identical APIs, so nothing in the code signals a mismatch. Without CLAUDE.md stating the actual driver, Claude Code defaults to whichever one appeared most often in training data — usually node-postgres — even on a project wired to D1 or Neon’s HTTP endpoint.

Why shouldn’t I use drizzle-kit push in production?

It applies a schema diff immediately with no migration file, no confirmation, and no history, and it can silently drop a column if a rename wasn’t recognized as one. It’s built for local iteration speed; generate + migrate is the only workflow that leaves a reviewable, revertible trail.

Why does db.query.posts.findMany({ with: { author: true } }) return undefined for author?

The relational query API only knows about relationships declared via relations() — it doesn’t infer them from foreign key columns. A working FK with no matching relations() block returns undefined for the with clause instead of throwing.

Does the relational query API return the same shape as a manual join?

No. db.query.* returns plain nested objects (post.author.name); db.select().leftJoin() returns rows namespaced by table (row.users.name), with null fields instead of an undefined property when a join finds no match.

Do all Drizzle drivers support transactions the same way?

No. TCP/websocket drivers (node-postgres, postgres-js, neon-serverless, bun-sql, libsql) support full interactive db.transaction(). HTTP-based drivers (neon-http, d1) are limited to atomic db.batch() calls without mid-transaction branching.


Real Drizzle ORM CLAUDE.md/AGENTS.md examples, including driver-specific setups for Postgres, D1, and Neon, are browsable in our gallery. For the equivalent template covering Prisma’s migration and connection-pooling rules, see CLAUDE.md for Prisma ORM. For the general-purpose GraphQL layer that often sits on top of either ORM, see CLAUDE.md for GraphQL API Rules.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules