Drizzle ORM skill for Claude Code
Typed SQL, reviewed migrations, zero drift. Claude writes Drizzle schemas as the single source of truth: timezone-aware timestamps, named indexes on every foreign key, types inferred with $inferSelect instead of duplicated interfaces. It keeps push for local prototyping only, reads each generated SQL file for accidental drop-and-add renames, and picks between the core builder and relational queries based on what the result needs.
- name
drizzle-orm - size239 lines · ≈2,400 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds type-safe data layers with Drizzle ORM - pgTable/sqliteTable/mysqlTable schemas, indexes and constraints, drizzle-kit generate/migrate/push/pull workflows, the SQL-like query builder and relational queries (db.query with "with"), transactions, prepared statements, and inferred types with $inferSelect/$inferInsert. Use when editing a Drizzle schema file or drizzle.config.ts, generating or applying drizzle-kit migrations, writing Drizzle queries, or wiring Drizzle to Postgres, SQLite/libSQL/D1, or MySQL drivers. Try asking
- Set up Drizzle with Postgres and generate the first migration for these tables
- Write a Drizzle query that returns each user with their 10 latest posts
- drizzle-kit generate wants to drop my column - what went wrong?
Use it when
- You are editing a Drizzle schema file or drizzle.config.ts
- You need to generate, review, or apply drizzle-kit migrations
- You are writing joins, upserts, or nested relational queries in Drizzle
- You are connecting Drizzle to Neon, libSQL, D1, or better-sqlite3
Skip it when
- The project uses Prisma - use prisma-orm
- The change is a risky DDL operation on a large live table - pair with schema-migrations
How to check the result
Running drizzle-kit generate after applying changes yields no new migration, drizzle-kit check passes, and tsc compiles with inferred row types.
The common mistake it prevents
Using drizzle-kit push on a database that is also managed by migration files creates drift, and the next generated migration tries to recreate objects that already exist.
Drizzle is SQL with types. Work is done well when the schema file is the single source of truth, every generated migration is reviewed SQL committed to the repo, queries read like the SQL they produce, and types come from inference rather than hand-written interfaces.
Identify the setup first
cat package.json | grep -E '"(drizzle-orm|drizzle-kit|drizzle-zod|pg|postgres|@neondatabase/serverless|better-sqlite3|@libsql/client|mysql2)"'
cat drizzle.config.ts
- Driver determines the import path:
drizzle-orm/node-postgres(pg),drizzle-orm/postgres-js,drizzle-orm/neon-http,drizzle-orm/better-sqlite3,drizzle-orm/libsql,drizzle-orm/d1,drizzle-orm/mysql2. - Relational queries API: the 0.x line uses
relations()per table; the 1.0 line (beta through 2025) introduces Relational Queries v2 with a singledefineRelations()and object-stylewhere. Match the installed version; do not mix styles. - Keep
drizzle-ormanddrizzle-kitversions compatible (upgrade them together).
Config
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql", // "sqlite" | "mysql" | "turso" | "singlestore"
schema: "./src/db/schema.ts", // file, glob, or array
out: "./drizzle",
dbCredentials: { url: process.env.DATABASE_URL! },
casing: "snake_case", // camelCase keys in TS -> snake_case columns
strict: true, // prompt before destructive push statements
verbose: true,
});
If you set casing in the kit config, pass the same casing to drizzle() at runtime,
or column names will not match.
Schema
// src/db/schema.ts
import { pgTable, pgEnum, integer, text, timestamp, uuid, boolean, index, uniqueIndex, check } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const postStatus = pgEnum("post_status", ["draft", "published", "archived"]);
export const users = pgTable("users", {
id: uuid().primaryKey().defaultRandom(),
email: text().notNull(),
name: text(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
}, (t) => [
uniqueIndex("users_email_lower_key").on(sql`lower(${t.email})`),
]);
export const posts = pgTable("posts", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
authorId: uuid().notNull().references(() => users.id, { onDelete: "cascade" }),
title: text().notNull(),
status: postStatus().notNull().default("draft"),
priceCents: integer().notNull(),
published: boolean().notNull().default(false),
publishedAt: timestamp({ withTimezone: true }),
}, (t) => [
index("posts_author_id_idx").on(t.authorId),
index("posts_status_published_at_idx").on(t.status, t.publishedAt.desc()),
check("posts_price_nonnegative", sql`${t.priceCents} >= 0`),
]);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
Rules:
- The third argument returns an array of indexes/constraints (the older object-return form is deprecated).
timestamp({ withTimezone: true })for real events; Drizzle’s default is without time zone. Usemode: "date"(default) ormode: "string"consistently.- Index every FK column explicitly;
.references()creates the constraint only. - Name indexes and constraints explicitly so migrations are stable and readable.
- Prefer
generatedAlwaysAsIdentity()overserial()for integer keys on Postgres. - Money as integer minor units or
numeric({ precision: 19, scale: 4 })(returns a string by default; parse deliberately).
Migrations with drizzle-kit
| Goal | Command |
|---|---|
| Create SQL migration from schema diff | npx drizzle-kit generate --name add_posts |
| Apply pending migrations | npx drizzle-kit migrate (or the runtime migrate() in a release step) |
| Hand-written migration (data fix, CONCURRENTLY, triggers) | npx drizzle-kit generate --custom --name backfill_slugs |
| Push schema directly, no migration files | npx drizzle-kit push (local prototyping only) |
| Introspect existing DB into a schema file | npx drizzle-kit pull |
| Check migration folder consistency (e.g. after merges) | npx drizzle-kit check |
| Browse data | npx drizzle-kit studio |
Programmatic migrate in a deploy step:
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
const db = drizzle(process.env.DATABASE_URL!);
await migrate(db, { migrationsFolder: "./drizzle" });
Hard rules:
- Pick one workflow per environment.
pushagainst a database that is also managed by migration files causes drift. Neverpushto staging or production. - Read every generated
.sqlfile. drizzle-kit asks interactively whether a changed column is a rename or a new column; answering wrong produces DROP + ADD and data loss. In non-interactive environments this prompt cannot be answered, so generate locally. - Do not edit migrations already applied anywhere; do not delete files from
out/or themeta/_journal.jsonentries by hand. - After merging branches that both generated migrations, regenerate or reconcile and run
drizzle-kit checkbefore deploying. - Large-table changes (NOT NULL, new indexes, type changes) need lock-safe SQL; write a
custom migration following schema-migrations.
CREATE INDEX CONCURRENTLYmust run outside a transaction; if the migrator wraps migrations in a transaction, run that statement in a separate step.
Queries
Core builder (reads like SQL):
import { and, eq, gt, desc, inArray, sql, count } from "drizzle-orm";
const recent = await db
.select({ id: posts.id, title: posts.title, author: users.name })
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(and(eq(posts.status, "published"), gt(posts.publishedAt, since)))
.orderBy(desc(posts.publishedAt), desc(posts.id))
.limit(20);
const [{ total }] = await db.select({ total: count() }).from(posts).where(eq(posts.authorId, userId));
// Upsert
await db.insert(users).values({ email, name })
.onConflictDoUpdate({ target: users.email, set: { name } })
.returning();
Relational queries (nested results, no manual mapping). Requires passing the schema (and
relations) to drizzle():
import * as schema from "./schema";
const db = drizzle(process.env.DATABASE_URL!, { schema, casing: "snake_case" });
const userWithPosts = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, userId),
columns: { id: true, name: true },
with: { posts: { columns: { id: true, title: true }, limit: 10 } },
});
Choose:
| Need | Use |
|---|---|
| Nested object graph for an API response | db.query.*.findMany/findFirst with with |
| Aggregates, group by, complex joins, CTEs | Core select() builder ($with for CTEs) |
| Something the builder cannot express | sql`...` template (parameterized) inside a query, or db.execute(sql…) |
- Keyset pagination:
where(lt(posts.id, cursor)).orderBy(desc(posts.id)).limit(n); avoid large.offset(). inArray(col, [])with an empty array is handled, but guard optional filters: anand()of all-undefinedconditions yields no WHERE clause, so an update or delete would hit every row.sql.raw()bypasses parameterization; only for trusted identifiers.- Hot paths: prepared statements.
const byId = db.select().from(users).where(eq(users.id, sql.placeholder("id"))).prepare("user_by_id");
const u = await byId.execute({ id });
Transactions
await db.transaction(async (tx) => {
const [acct] = await tx.select().from(accounts).where(eq(accounts.id, from)).for("update");
if (acct.balance < amount) tx.rollback(); // throws, rolls back
await tx.update(accounts).set({ balance: sql`${accounts.balance} - ${amount}` }).where(eq(accounts.id, from));
await tx.update(accounts).set({ balance: sql`${accounts.balance} + ${amount}` }).where(eq(accounts.id, to));
}, { isolationLevel: "read committed" });
- Use
tx, notdb, inside the callback. - Keep transactions short; no network calls inside.
- Drivers differ:
neon-httpand D1 do not support interactive transactions; usedb.batch([...])for atomic batches there. - Prefer atomic SQL updates (
balance - amount) over read-then-write.
Validation
Generate Zod schemas from tables so API validation matches the DB (drizzle-zod, or the built-in integration in newer versions):
import { createInsertSchema } from "drizzle-zod";
export const insertPostSchema = createInsertSchema(posts, { title: (s) => s.min(1).max(200) });
Gotchas
- Column order and naming come from your keys plus
casing; changingcasinglater makes drizzle-kit think every column was renamed. $inferSelectreflects the schema file, not the live DB. If someone altered the DB by hand, types lie until youpullor migrate.numeric/decimalandbigint(inmode: "bigint") do not serialize to JSON as numbers; convert at the boundary..returning()is unavailable on MySQL; use$returningId()there.- Serverless Postgres: use the provider’s pooled URL for queries and a direct URL for migrations.
Verification
npx drizzle-kit generateproduces no new migration after your changes are applied (schema and migrations are in sync);npx drizzle-kit checkpasses.- Review the generated SQL diff in the PR.
tsc --noEmitpasses; no hand-written row types duplicating$inferSelect.- Log SQL during development with
drizzle(url, { logger: true })and EXPLAIN any slow statement (see sql-query-tuning).
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.