Drizzle ORM skill for Codex
TypeScript schema in, reviewed SQL migrations out. Codex reads the installed Drizzle version before writing code, so relations use relations() on 0.x and defineRelations() on 1.x. Schema changes go through drizzle-kit generate with every SQL file reviewed, renames are written by hand instead of answered at an interactive prompt, push stays off shared databases, and queries select explicit columns with filters that can never collapse into an update of every row.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- drizzle-orm Installs to ~/.agents/skills/drizzle-orm/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install drizzle-orm The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project drizzle-orm Installs to .agents/skills/drizzle-orm/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/drizzle-orm.zipunzip -o drizzle-orm.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) drizzle-orm PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $drizzle-orm, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes Drizzle ORM schemas, queries and migrations in TypeScript for PostgreSQL, MySQL and SQLite - pgTable definitions with indexes and constraints, the SQL-like select builder and relational queries, transactions, the sql template, and drizzle-kit generate, migrate, push, pull and check - including the v1 changes (defineRelations, new migration folder layout). Use when editing a Drizzle schema file, writing drizzle-kit migrations, choosing push versus migrate, wiring a driver (node-postgres, postgres.js, Neon, libSQL, D1), or fixing a slow or wrongly typed Drizzle query.
Use it when
- Defining or changing tables in a Drizzle schema file
- Generating, reviewing or applying drizzle-kit migrations
- Writing joins, upserts, relational queries or transactions in Drizzle
- Choosing a Drizzle driver for Node, serverless or edge
Use something else when
- You want a SQL query builder without an ORM schema layer: use kysely-query-builder
- The project is on Prisma: use prisma-orm
How to check the result
tsc passes, a second drizzle-kit generate finds no pending changes, drizzle-kit check is clean, and all migrations apply to a fresh database before tests run.
The mistake it prevents
Answering drizzle-kit's rename prompt with create-and-drop, which ships a migration that deletes the old column's data.
Done well means: the TypeScript schema is the single source of truth, every change ships
as a reviewed SQL migration from drizzle-kit generate, queries read like the SQL they
emit, and nothing in production depends on drizzle-kit push.
Inspect first
- Read
AGENTS.mdfor the package manager and how migrations are applied (CLI step, app startup, or CI job). - Check versions in
package.json:drizzle-ormanddrizzle-kit. Versions0.xuse relations v1 (relations());1.x(including@rc/@betatags) usesdefineRelations()and a folder-per-migration layout. Follow what is installed. - Open
drizzle.config.tsfordialect,schemapath,outfolder and credentials. - List the
outfolder (drizzle/by default) to see whether the team usesgenerateplusmigrate(folder has SQL files) or onlypush(no files). - Find the
drizzle()call to learn the driver.
drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql", // "mysql" | "sqlite" | "turso" | "singlestore"
schema: "./src/db/schema.ts", // or a glob / folder
out: "./drizzle",
dbCredentials: { url: process.env.DATABASE_URL! }, // direct URL, not a transaction pooler
strict: true,
verbose: true,
});
Schema
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex, pgEnum } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const orderStatus = pgEnum("order_status", ["pending", "paid", "shipped"]);
export const orders = pgTable("orders", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull(),
customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "restrict" }),
status: orderStatus("status").notNull().default("pending"),
totalCents: integer("total_cents").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => [
index("orders_tenant_created_idx").on(t.tenantId, t.createdAt.desc()),
index("orders_customer_idx").on(t.customerId),
index("orders_pending_idx").on(t.createdAt).where(sql`${t.status} = 'pending'`),
]);
export type Order = typeof orders.$inferSelect;
export type NewOrder = typeof orders.$inferInsert;
- Export every table, enum and relation from the schema entry: drizzle-kit only sees exports.
- Use the array form for the third argument (indexes, checks, composite keys); the object form is deprecated.
- Index foreign keys yourself;
references()creates only the constraint. timestampwithoutwithTimezoneistimestamp without time zone; usewithTimezone: true..$onUpdate(() => new Date())runs in the app, not the database; a raw SQL update will not touch it.- Name indexes explicitly so diffs stay stable across refactors.
Migrations: generate and migrate
npx drizzle-kit generate --name add_order_status # writes SQL from the schema diff
npx drizzle-kit migrate # applies pending migrations
npx drizzle-kit check # consistency of the migration history
npx drizzle-kit generate --custom --name backfill_status # empty file for hand-written SQL
Or apply at deploy time from code:
import { migrate } from "drizzle-orm/node-postgres/migrator";
await migrate(db, { migrationsFolder: "./drizzle" });
Decision table:
| Situation | Use |
|---|---|
| Shared, staging or production database | generate + migrate, files committed |
| Local prototype or disposable branch database | push is acceptable |
| Existing database, adopting Drizzle | pull to introspect, then baseline |
Data backfill or CREATE INDEX CONCURRENTLY | generate --custom, hand-written SQL |
Rules:
generateasks interactively whether a column was renamed or dropped and recreated. Codex cannot answer TTY prompts reliably: make a rename its own change, rungeneratein a terminal the user controls, or write theALTER TABLE ... RENAME COLUMNin a custom migration. Never accept a drop-and-create for a rename.- Read every generated SQL file before committing. Check for
DROP, type changes that rewrite the table, andNOT NULLadditions on populated tables. - Put
CREATE INDEX CONCURRENTLYalone in a custom migration; check that the migrator in use does not wrap it in a transaction, or run it as a separate deploy step. pushagainst any shared database, andpush --force, require explicit user confirmation:pushapplies destructive statements with no history to roll back from.- Never edit a migration that has been applied elsewhere; add a new one.
Queries
import { and, eq, gte, desc, inArray, sql } from "drizzle-orm";
const recent = await db
.select({ id: orders.id, total: orders.totalCents, customer: customers.name })
.from(orders)
.innerJoin(customers, eq(customers.id, orders.customerId))
.where(and(eq(orders.tenantId, tenantId), gte(orders.createdAt, since)))
.orderBy(desc(orders.createdAt), desc(orders.id))
.limit(50);
const [created] = await db.insert(orders).values(newOrder).returning();
await db.insert(customers).values(rows)
.onConflictDoUpdate({ target: [customers.tenantId, customers.email], set: { name: sql`excluded.name` } });
await db.update(orders).set({ status: "paid" }).where(eq(orders.id, id));
updateanddeletewithout.where()affect every row. Build filters so an empty input cannot produce a missingwhere.sqltemplate interpolations are parameters.sql.raw()inlines text: never pass user input.- Aggregates:
sql<number>\count(*)`.mapWith(Number)since Postgres returnscount` as bigint text. - Select explicit columns in hot paths instead of
db.select().from(t).
Relational queries
v0 (relations()):
export const ordersRelations = relations(orders, ({ one, many }) => ({
customer: one(customers, { fields: [orders.customerId], references: [customers.id] }),
items: many(orderItems),
}));
const db = drizzle({ client: pool, schema });
const rows = await db.query.orders.findMany({
columns: { id: true, totalCents: true },
with: { items: { columns: { sku: true, qty: true } } },
where: (o, { eq }) => eq(o.tenantId, tenantId),
limit: 50,
});
v1 (defineRelations(), passed as relations to drizzle(); where becomes an object
filter such as where: { tenantId }):
export const relations = defineRelations({ orders, customers, orderItems }, (r) => ({
orders: {
customer: r.one.customers({ from: r.orders.customerId, to: r.customers.id }),
items: r.many.orderItems(),
},
}));
Relational queries emit a single SQL statement with JSON aggregation, so nested with
never causes N+1. For very wide fan-outs, compare against two plain selects joined in memory.
Transactions
await db.transaction(async (tx) => {
const [acct] = await tx.update(accounts)
.set({ balance: sql`${accounts.balance} - ${amount}` })
.where(eq(accounts.id, from)).returning();
if (acct.balance < 0) tx.rollback(); // throws, rolls back
await tx.update(accounts).set({ balance: sql`${accounts.balance} + ${amount}` }).where(eq(accounts.id, to));
}, { isolationLevel: "serializable" });
Use tx, not db, inside the callback; a stray db call runs outside the transaction.
Drivers
| Runtime | Import | Note |
|---|---|---|
| Node, long-lived | drizzle-orm/node-postgres (pg Pool) | Set pool max explicitly |
| Node, postgres.js | drizzle-orm/postgres-js | Behind a transaction pooler pass prepare: false |
| Serverless/edge Postgres | drizzle-orm/neon-http or neon-serverless | HTTP driver has no interactive transactions |
| SQLite / Turso | drizzle-orm/better-sqlite3, drizzle-orm/libsql | |
| Cloudflare D1 | drizzle-orm/d1 | Migrations via wrangler or drizzle-kit |
| MySQL | drizzle-orm/mysql2 | No RETURNING; use $returningId() |
Gotchas
- Two schema files defining the same table name give confusing diffs; keep one owner.
drizzle-kitreadsdbCredentialsat CLI time;.envmay not be loaded unless the config importsdotenv/config.- Postgres enums: drizzle-kit can add values, but removing or renaming needs a manual migration.
- Running
migratethrough a transaction-mode pooler can fail on session-level features; use the direct URL for migrations. - In v1,
pushandpullcover all schemas, not onlypublic; setschemaFilterif the database holds schemas the app must not manage.
Verification
npx tsc --noEmitpasses; inferred types match expectations at call sites.npx drizzle-kit generateright after your change reports no further schema changes.npx drizzle-kit checkis clean.- Apply all migrations to a fresh database (container or branch) and run the tests.
- For a performance fix, log SQL with
drizzle({ client, logger: true })and runEXPLAIN ANALYZEon the emitted statement.
Output
**Schema:** <tables/columns/indexes changed>
**Migration:** drizzle/<name> (generated | custom), destructive statements: none | <list>
**Apply with:** drizzle-kit migrate | migrate() at startup | CI job
**Checks run:** tsc, drizzle-kit check, fresh-database apply, tests What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Drizzle ORM"
short_description: "TypeScript schema in, reviewed SQL migrations out"
default_prompt: "$drizzle-orm Add an orders table with a tenant index to our Drizzle schema and generate the migration" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.