← All posts
·12 min read

Claude Code with Drizzle ORM: Schema, Migrations, Queries

Claude CodeDrizzleORMWorkflow
Claude Code with Drizzle ORM: Schema, Migrations, Queries

Why Drizzle and Claude Code work well together

Drizzle is a thin, TypeScript-first SQL builder rather than a heavy ORM. The schema is plain TypeScript, queries return inferred types, and the migration tool reads your schema files directly. Each of those properties is exactly what Claude Code needs to produce reliable database code.

When the schema lives in TypeScript, Claude edits it like any other module: no separate DSL to translate. Queries return inferred types, so the type checker catches mistakes before they reach the database. The migration tool reads schema files directly, so drizzle-kit generate after a schema edit produces SQL that reflects the change.

Without a project-specific CLAUDE.md, Claude will mix the Core API with Relational Queries in the same file, push schema changes using drizzle-kit push when migrations are required, break type inference by typing client variables as any, and pick the wrong driver for the runtime (node-postgres in a Cloudflare Worker, for example). Each is avoidable with a few hundred lines of configuration.

This guide covers the CLAUDE.md template, schema conventions, migration workflow, query patterns, and edge runtime rules that produce a Drizzle project Claude can confidently work in. If you are setting up Claude Code for the first time, the Claude Code setup guide covers installation and authentication before any of this applies.

The Drizzle CLAUDE.md

The CLAUDE.md at your project root is read before every Claude Code session. For a Drizzle project, it needs to answer: what database and driver are in use, where is the schema, how is the client initialized, which migration commands are valid, and which query API style is the project standard.

# Drizzle project rules

## Stack
- Node: 20.x
- TypeScript: 5.x (strict mode enabled)
- Drizzle ORM: 0.36.x
- drizzle-kit: 0.27.x
- Database: PostgreSQL 16 via Neon serverless driver
- Driver: @neondatabase/serverless (NOT pg/postgres-js, runtime is edge)
- Client: singleton in lib/db.ts, never instantiate db elsewhere

## Project structure
- Schema: src/db/schema.ts (single file, all tables)
- Relations: src/db/relations.ts (relation() definitions for relational queries)
- Migrations: drizzle/ (auto-generated by drizzle-kit, do not hand-edit)
- Drizzle config: drizzle.config.ts (root)
- Client: lib/db.ts (singleton export of drizzle instance)
- Types: src/db/types.ts for inferred types ($inferSelect, $inferInsert)

## drizzle-kit commands
- After schema edit: `npx drizzle-kit generate` then `npx drizzle-kit migrate`
- Inspect schema diff before generating: `npx drizzle-kit check`
- Studio for ad-hoc browsing: `npx drizzle-kit studio`
- Pull schema from existing DB: `npx drizzle-kit pull` (introspection only)

## Hard rules
- NEVER use `drizzle-kit push` on this project, generate + migrate only
- NEVER hand-edit files in drizzle/ (the migration journal will desync)
- NEVER mix Core API and Relational Queries API in the same module
- NEVER instantiate drizzle() outside lib/db.ts
- ALWAYS run `drizzle-kit generate` after editing schema.ts
- ALWAYS commit the generated SQL file alongside the schema change

Three sections prevent the most common Claude Code failures with Drizzle.

The commands section blocks drizzle-kit push in favour of generate + migrate. Push bypasses the migration journal, which means production cannot be brought in sync from a push-only history. Claude defaults to push because the iteration is faster; the explicit rule removes that choice.

The driver rule matters because Drizzle supports multiple drivers for the same database. PostgreSQL alone has pg, postgres-js, @neondatabase/serverless, and @vercel/postgres, each appropriate for a different runtime. Without a rule, Claude picks the most popular driver (pg) regardless of whether the project deploys to Node, a Cloudflare Worker, or a Vercel Edge function.

The API style rule is unique to Drizzle. The Core API (db.select().from(users)...) and the Relational Queries API (db.query.users.findFirst(...)) cover overlapping use cases. Mixing them in the same module produces inconsistent code that Claude will keep mixing unless told to pick one.

Schema definition with Claude Code

Drizzle's schema is plain TypeScript, so Claude treats it like any other module. The model is: declare each table as a const, declare relations in a separate file, and export inferred types from a third. Claude reproduces this structure reliably once the convention is in CLAUDE.md.

## Schema conventions

### Table naming: snake_case in DB, camelCase exported const
### Column naming: snake_case in DB, camelCase in TS via column key
### Required: every table has id (uuid or serial), createdAt, updatedAt

import { pgTable, serial, text, timestamp, integer, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  emailIdx: uniqueIndex('users_email_idx').on(table.email),
}))

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content'),
  published: boolean('published').default(false).notNull(),
  authorId: integer('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  authorIdx: index('posts_author_idx').on(table.authorId),
  publishedCreatedIdx: index('posts_published_created_idx').on(table.published, table.createdAt),
}))

### Index rules
- Foreign key columns always get an index() entry
- Columns used in WHERE with high cardinality get index()
- Composite indexes for filter + sort patterns
- Unique constraints via uniqueIndex() or .unique() on the column
- onDelete behaviour explicit on every references()

The same structure works with sqliteTable for libsql and Turso, or mysqlTable for MySQL and PlanetScale. Swap the import path and the column types; the rest of the pattern stays identical.

The relations file is where Drizzle's relational query API gets its type information. Without it, the relational API returns unknown for every joined relation, and Claude either falls back to the Core API or writes manual joins that are not type-safe.

import { relations } from 'drizzle-orm'
import { users, posts } from './schema'

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

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}))

The inferred types file is the third pattern. Drizzle infers $inferSelect and $inferInsert per table, but you have to export them somewhere central or every consumer infers its own type. Centralising in src/db/types.ts keeps query function signatures clean.

import { users, posts } from './schema'

export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Post = typeof posts.$inferSelect
export type NewPost = typeof posts.$inferInsert

For TypeScript-specific rules that apply across any Drizzle codebase, the Claude Code TypeScript guide covers strict mode patterns, type narrowing, and inference rules that Drizzle builds on.

drizzle-kit migrations: push vs generate

drizzle-kit offers two workflows for applying schema changes, and choosing the wrong one is the most common Drizzle setup mistake. The CLAUDE.md rule above blocks push, but it is worth understanding why.

drizzle-kit push reads your schema and applies the diff to the database directly: no SQL file, no migration journal, no review step. Fast and frictionless for prototyping a fresh database, and the wrong tool for any project that has been deployed once.

drizzle-kit generate reads your schema, compares it to the migration journal, and writes a SQL file describing the diff. drizzle-kit migrate then applies pending SQL files. Every change has a SQL artifact that can be reviewed, version-controlled, and replayed in any environment.

The decision rule for CLAUDE.md:

## Migration workflow decision

### When to use push (prototyping only)
- Brand new project, no production deploy yet
- Throwaway branch experimenting with schema shape
- Local-only test database that resets frequently

### When to use generate + migrate (default for this project)
- Project is deployed anywhere
- Multiple developers share the schema
- You need a record of what changed when
- You need to replay changes in CI/staging/production

### Steps for every schema change
1. Edit src/db/schema.ts
2. Run `npx drizzle-kit check` to preview the diff
3. Run `npx drizzle-kit generate --name <descriptive-kebab-case-name>`
4. Review the generated SQL in drizzle/<timestamp>_<name>.sql
5. Run `npx drizzle-kit migrate` to apply locally
6. Commit schema.ts AND the generated SQL together

Naming matters as much for Drizzle as for any migration system. add_user_email_verification is a useful change log entry. update_schema is not. The --name flag forces a description; kebab-case keeps file names readable.

Reviewing the generated SQL takes thirty seconds and catches the cases where Drizzle interprets a schema change as something more destructive than you intended. Renaming a column, in particular, will be generated as DROP COLUMN followed by ADD COLUMN unless you map the rename. That sequence drops the data. The fix is to use the --custom flag and add a RENAME COLUMN statement, or to do the rename across two migrations with a backfill in between. Type changes have the same caveat: going from integer to bigint in PostgreSQL is non-destructive at the SQL level, but drizzle-kit may generate a conservative DROP and ADD if it cannot determine the change is safe. Reviewing the SQL before running it is the only way to catch this.

For a deeper guide to the PostgreSQL specifics Drizzle builds on, the Claude Code PostgreSQL guide covers connection management, advisory locks, and concurrent index creation.

Query patterns: Core API vs Relational Queries

Drizzle gives you two ways to write the same query. The Core API is a SQL builder that reads like SQL. The Relational Queries API is a higher-level interface that handles joins through the relations you defined.

Core API:

import { db } from '@/lib/db'
import { users, posts } from '@/db/schema'
import { eq, desc } from 'drizzle-orm'

const userWithPosts = await db
  .select({
    id: users.id,
    name: users.name,
    postId: posts.id,
    postTitle: posts.title,
  })
  .from(users)
  .leftJoin(posts, eq(posts.authorId, users.id))
  .where(eq(users.id, 1))
  .orderBy(desc(posts.createdAt))

Relational Queries API:

import { db } from '@/lib/db'
import { eq } from 'drizzle-orm'

const userWithPosts = await db.query.users.findFirst({
  where: (users, { eq }) => eq(users.id, 1),
  with: {
    posts: {
      orderBy: (posts, { desc }) => desc(posts.createdAt),
    },
  },
})

Both queries return typed data. The Core API gives you a flat row shape that needs manual aggregation if you want a User & { posts: Post[] } structure. The Relational Queries API returns the nested structure directly because the relations file describes the shape. Pick one as the project default; mixing them works at runtime but produces inconsistent code that Claude will keep mixing.

## Query API standard

### Default: Relational Queries API for reads with joins
- db.query.<table>.findFirst / findMany for relation-heavy reads
- Returns nested objects, type-safe via relations file
- Use `with` for joins, `where` callbacks for filtering

### Use Core API when
- Aggregations: db.select({ count: count() }).from(...)
- Bulk operations: db.insert(...).values([...])
- Complex SQL not expressible in relational API
- Subqueries, window functions, CTEs
- All writes (insert, update, delete) use Core API

### Insert pattern (Core API)
const [user] = await db.insert(users).values({ email, name }).returning()

### Update pattern (Core API)
await db.update(users).set({ name: 'New' }).where(eq(users.id, id))

### Delete pattern (Core API)
await db.delete(users).where(eq(users.id, id))

### Transactions
await db.transaction(async (tx) => {
  const [user] = await tx.insert(users).values({ email }).returning()
  await tx.insert(posts).values({ authorId: user.id, title })
})

The split between read and write is the practical compromise. Reads benefit from the nested return shapes of the relational API; writes are simpler in the Core API and the relational API does not support multi-table inserts in a single call anyway.

The deeper reason this rule matters is type inference. The relational API infers nested types from the relations file, so Claude gets compile-time errors when a with clause references a relation that does not exist. Raw leftJoin calls break that inference because the result row is a flat shape Claude has to type manually. Sticking to the relational API for joined reads keeps the type system doing the work.

Edge and serverless patterns: libsql, Neon, Turso

Drizzle's edge runtime story is one of the strongest reasons to choose it. The same schema and query code runs against Postgres on a Node server, libsql on Turso, and Neon's serverless driver on a Cloudflare Worker. The differences are in driver selection and connection lifecycle.

The most common mistake Claude makes is picking the wrong driver. The pg package opens a TCP connection that requires a long-lived process; it does not work in Cloudflare Workers, Vercel Edge functions, or Deno Deploy. Two patterns cover most projects:

// Edge runtime (Cloudflare Workers, Vercel Edge): Neon HTTP
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'

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

// Turso (any runtime, including edge)
import { drizzle } from 'drizzle-orm/libsql'
import { createClient } from '@libsql/client'

const client = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
})
export const db = drizzle(client, { schema })

// Node runtime (Express, Fastify, Next.js Node): node-postgres
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'

export const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }), { schema })

PlanetScale uses drizzle-orm/planetscale-serverless for edge-compatible MySQL; mysql2 and better-sqlite3 cover the Node side for those engines.

Add this to CLAUDE.md so Claude picks the right one without prompting:

## Driver selection

### Runtime: Cloudflare Workers / Vercel Edge / Deno Deploy
- Postgres: drizzle-orm/neon-http with @neondatabase/serverless
- SQLite: drizzle-orm/libsql with @libsql/client/web
- MySQL: drizzle-orm/planetscale-serverless

### Runtime: Node.js (Express, Fastify, Next.js Node runtime)
- Postgres: drizzle-orm/node-postgres with pg
- SQLite: drizzle-orm/better-sqlite3 (file-based) or libsql (remote)
- MySQL: drizzle-orm/mysql2

### NEVER do this
- pg in a Worker (no TCP)
- mysql2 in an Edge function (no TCP)
- better-sqlite3 in any serverless runtime (file system unavailable)

The schema argument to drizzle() is required for the relational queries API. Without it, db.query is undefined. Claude omits this regularly when initialising the client because the basic Core API works without it. Putting { schema } in the CLAUDE.md template removes the omission.

For deployments to Cloudflare specifically, the Claude Code Cloudflare Workers guide covers wrangler config, environment bindings, and the deployment patterns that pair with Drizzle's edge drivers.

Hard rules and conclusion

The patterns in this guide produce a Drizzle project where Claude Code can reliably edit the schema, generate migrations, write queries, and pick the right driver for the runtime. Each rule maps to a specific failure mode: schema spread across files, queries mixing API styles, migrations applied via push, drivers chosen by familiarity rather than runtime.

Environment variable handling for database URLs and auth tokens is its own topic, and the Claude Code environment variables guide covers the patterns that apply across any project. The underlying principle matches every Claude Code best practices workflow: Claude performs at the level of context provided. With the rules above in CLAUDE.md, Claude treats the schema as the source of truth, generates reviewable SQL, writes queries in a consistent API style, and picks the right driver for the runtime on the first try.

If you are evaluating Drizzle against Prisma, the trade-off is shaped by what you value. Prisma gives you a higher-level data layer with a separate schema language, generated client, and more opinionated migration tooling. Drizzle gives you a thinner SQL builder that stays in TypeScript, infers types directly from your schema, and ships with first-class edge runtime support. Neither is universally better. Prisma tends to win on developer experience for CRUD-heavy applications. Drizzle tends to win on edge deployment, type inference fidelity, and projects where SQL is the right answer. The Claude Code Prisma guide covers the equivalent setup if you want to compare directly.

For the broader pattern of how project-specific configuration shapes Claude's behaviour, the CLAUDE.md explained post covers structure, sectioning, and rule precedence. If your migration step ever fails in production, the Claude Code debugging guide walks through triaging database errors with Claude as a debugging partner.

Start with the template above, generate one migration with Claude to verify the workflow, and run a query that uses the relational API end to end. The investment pays back within the first day. Claudify ships a Drizzle-specific CLAUDE.md template as part of the Claude Code workflow kit, pre-configured with schema conventions, migration guardrails, query API rules, and runtime-aware driver selection for TypeScript projects.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir