| name | drizzle-orm |
| category | Backend |
| description | MUST USE when writing or reviewing Drizzle ORM schemas, migrations, relational queries, or drizzle-kit configuration. Enforces identity columns over serial, proper relation definitions, migration safety, type inference, and query patterns. |
Drizzle ORM Best Practices
Schema — Use Identity Columns, Not Serial
import { pgTable, serial, text } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name"),
});
import { pgTable, integer, text } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: text("name"),
});
Schema — Column Naming
Map camelCase TypeScript to snake_case SQL explicitly.
export const users = pgTable("users", {
firstName: varchar({ length: 256 }),
});
export const users = pgTable("users", {
firstName: varchar("first_name", { length: 256 }),
});
Schema — Indexes and Constraints
Define indexes in the third argument array:
export const posts = pgTable(
"posts",
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
slug: varchar({ length: 256 }),
title: varchar({ length: 256 }),
ownerId: integer("owner_id").references(() => users.id),
},
(table) => [
uniqueIndex("posts_slug_idx").on(table.slug),
index("posts_title_idx").on(table.title),
]
);
Schema — Enums, Timestamps, Foreign Keys
export const roleEnum = pgEnum("role", ["guest", "user", "admin"]);
export const posts = pgTable("posts", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
role: roleEnum().default("guest"),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at")
.notNull()
.$onUpdate(() => new Date()),
});
Schema — Type Inference
interface User { id: number; name: string; email: string; }
export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;
Relations — One-to-Many
import { relations } from "drizzle-orm";
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.userId],
references: [users.id],
}),
}));
Relations — Many-to-Many
export const usersToGroups = pgTable("users_to_groups", {
userId: integer("user_id")
.notNull()
.references(() => users.id),
groupId: integer("group_id")
.notNull()
.references(() => groups.id),
}, (t) => [
primaryKey({ columns: [t.userId, t.groupId] }),
]);
export const usersRelations = relations(users, ({ many }) => ({
groups: many(usersToGroups),
}));
export const groupsRelations = relations(groups, ({ many }) => ({
members: many(usersToGroups),
}));
Relational Queries — with and Filters
import * as schema from "./schema";
const db = drizzle(pool, { schema });
const result = await db
.select()
.from(users)
.leftJoin(posts, eq(users.id, posts.userId));
const result = await db.query.users.findMany({
with: {
posts: true,
},
});
const result = await db.query.users.findMany({
with: {
posts: {
where: (posts, { eq }) => eq(posts.published, true),
limit: 5,
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
},
},
});
Relations v2 — Landing in Drizzle v1.0
The relations API above is the stable 0.45.x syntax — keep using it until v1.0 ships. Drizzle v1.0 (currently drizzle-orm@rc) replaces per-table relations() with a single defineRelations, and you pass { relations } to drizzle() instead of { schema }. Know the shape so you recognize and can migrate v2 code:
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";
export const relations = defineRelations(schema, (r) => ({
users: { posts: r.many.posts({ from: r.users.id, to: r.posts.authorId }) },
posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id }) },
}));
const db = drizzle(client, { relations });
v2 queries also take object-style where / orderBy and can filter parent rows by a related table's columns (v1 can only filter children):
await db.query.users.findMany({ where: { id: 1 }, with: { posts: true } });
During the @rc migration window, v2 lives on db.query while your old v1-style callback queries keep working on db._query.
Queries — Select Only What You Need
const allUsers = await db.select().from(users);
const names = await db
.select({ id: users.id, name: users.name })
.from(users);
Queries — Insert, Update, Delete
const [newUser] = await db
.insert(users)
.values({ name: "Alice", email: "alice@example.com" })
.returning();
await db
.insert(users)
.values({ email: "alice@example.com", name: "Alice" })
.onConflictDoUpdate({
target: users.email,
set: { name: "Alice Updated" },
});
await db.update(users).set({ name: "Bob" }).where(eq(users.id, 1));
await db.delete(users).where(eq(users.id, 1));
Transactions
await db.insert(orders).values(order);
await db.update(inventory).set({ stock: sql`stock - 1` }).where(eq(inventory.id, itemId));
await db.transaction(async (tx) => {
await tx.insert(orders).values(order);
await tx
.update(inventory)
.set({ stock: sql`stock - 1` })
.where(eq(inventory.id, itemId));
});
Migrations — Config and Workflow
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
strict: true,
dbCredentials: { url: process.env.DATABASE_URL! },
});
drizzle-kit generate --name=add_posts_table
drizzle-kit migrate
drizzle-kit push
drizzle-kit pull
drizzle-kit generate --name=seed_users --custom
Migrations — Programmatic Apply
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
await migrate(db, { migrationsFolder: "./drizzle" });
await pool.end();
Migrations — Rename Columns Safely
Drizzle Kit may interpret renames as drop + add = data loss. With strict: true it will prompt you. Write a custom migration instead:
ALTER TABLE "users" RENAME COLUMN "name" TO "full_name";
Migrations — Add Non-Nullable Column Safely
Generate the column addition, then use --custom for backfill + constraint:
ALTER TABLE "users" ADD COLUMN "role" VARCHAR(20);
UPDATE "users" SET "role" = 'member' WHERE "role" IS NULL;
ALTER TABLE "users" ALTER COLUMN "role" SET NOT NULL;
Rules
- Use identity columns (
generatedAlwaysAsIdentity()) over serial for PostgreSQL
- Explicit snake_case column names — always pass the SQL name string
- Infer types from schema — use
$inferInsert / $inferSelect, never manual interfaces
- Define relations separately —
relations() calls live alongside table definitions
- Use relational query API (
db.query.X.findMany({ with })) for nested data
- Select only needed columns — avoid bare
select() in production queries
- Wrap multi-table writes in transactions —
db.transaction()
- Always review generated SQL before running
drizzle-kit migrate
- Enable
strict: true in drizzle config — catches ambiguous renames
- Never use
push in production — always use migration files via generate + migrate
- Three-step non-nullable columns — add nullable, backfill, set NOT NULL
- Commit migration files to version control — they are your database changelog
- Run
drizzle-kit generate in CI to detect schema drift