Skip to content
Chapter 41Lesson 3

Authoring the schema and init migration

This is where the invoicing data model becomes real database structure. You will author six tables and their relations in db/schema.ts and db/relations.ts, generate one migration, read the SQL it emits, and apply it to the empty Postgres your Docker container is already running. When you finish, pnpm db:migrate will have created organizations, users, org_members, customers, invoices, and invoice_lines in one reviewed step, and a second pnpm db:generate will report no changes, the signal that Drizzle’s snapshot and your schema agree. The tables stay empty; data fills them in the next lesson.

The schema is the source of truth for the project. Every row type and query in the lessons that follow derives from db/schema.ts, so you never hand-write a row type: declare the table, and $inferSelect returns a row shape that can never drift from the columns.

The starter supplies the tooling. drizzle.config.ts points Drizzle Kit at the unpooled URL, the schema path, and snake-case casing; src/db/index.ts exports the db client and a dbUnpooled alias with schema and relations wired in; and the db:generate / db:migrate / db:studio scripts run through dotenv-cli because Drizzle Kit and tsx do not read .env on their own.

The design decisions carry in from the Drizzle chapters. Every tenant-owned table carries organizationId as a NOT NULL foreign key; the one exception is users, which is global across organizations and reached through the org_members junction table. Primary keys are UUIDv7 generated by Postgres through default(sql`uuidv7()`), so they sort by creation time yet stay unguessable; timestamps are timestamp({ withTimezone: true }), money is numeric({ precision: 12, scale: 2 }) rather than a float, and memberRole and invoiceStatus are pgEnums so the database rejects an invalid status. ON DELETE is a per-edge decision: an owned child that means nothing without its parent cascades, while a referenced entity the schema needs (a customer, an invoice author) restricts so it cannot vanish under the rows pointing at it. Uniqueness and indexes are tenant-aware: an invoice number is unique within an organization, and each composite index’s column order matches the list query’s where plus orderBy direction so the planner can use it.

Reads and seeding come in the next two lessons. Avoid two traps: do not reach for drizzle-kit push, the prototype-only escape hatch this course replaces with the generate-and-commit loop, and do not migrate without reading the emitted SQL first.

pnpm db:migrate runs cleanly on the empty database and leaves exactly one row in __drizzle_migrations.
tested
After migrating, all six tables exist with their foreign keys (each carrying its intended ON DELETE), tenant-scoped uniques, the total >= 0 check, and the three named indexes with their DESC ordering.
tested
A second pnpm db:generate immediately after reports no changes: the snapshot is in sync with the schema.
untested
The two invoices → users edges resolve distinctly, so a nested read of an invoice’s author and its organization’s members does not cross-wire membership with authorship.
untested
The exported row types are derived from the schema, so a query selecting an invoice returns the typed shape queries.ts and the inspector consume; nothing downstream hand-types a row.
untested

Write db/schema.ts and db/relations.ts against the reference signatures from the project overview and the Lesson 3 tests. Run pnpm db:generate --name init_schema, open the 0000_init_schema.sql it writes and read it end to end, then run pnpm db:migrate. Try the whole loop before opening the walkthrough.

Reference solution and walkthrough

Build the file in foreign-key order: a table can only reference one that already exists, so organizations and users come first, then the junction and children that point back at them. The two pgEnums sit at the top so the columns that use them have them in scope.

Start with the enums and the two root tables. The named enums ('member_role', 'invoice_status') let the database reject an out-of-domain value. Note the ...timestamps spread from db/columns.ts: it adds the same createdAt column to every table from one definition, so the cursor’s sort key is identical everywhere.

src/db/schema.ts
import { sql } from 'drizzle-orm';
import {
check,
index,
integer,
numeric,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core';
import { timestamps } from '@/db/columns';
export const memberRole = pgEnum('member_role', ['owner', 'admin', 'member']);
export const invoiceStatus = pgEnum('invoice_status', [
'draft',
'sent',
'paid',
'overdue',
]);
export const organizations = pgTable('organizations', {
id: uuid().primaryKey().default(sql`uuidv7()`),
name: text().notNull(),
slug: text().notNull().unique('organizations_slug_unique'),
...timestamps,
});
export type Organization = typeof organizations.$inferSelect;
export type NewOrganization = typeof organizations.$inferInsert;
export const users = pgTable('users', {
id: uuid().primaryKey().default(sql`uuidv7()`),
email: text().notNull().unique('users_email_unique'),
name: text().notNull(),
...timestamps,
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

users is a stub. The authentication chapters own the real table through the Better Auth Drizzle adapter; here it gets just enough — id, unique email, name — for org_members.user_id and invoices.created_by to point at. The later table keeps these foreign-key targets, so swapping it in is additive.

Each table is followed by its $inferSelect and $inferInsert exports: the canonical row types. Organization is a row from a select, NewOrganization the shape you pass to an insert. Nothing downstream hand-types a row, so changing a column changes the types and any stale caller stops compiling.

Now the junction. org_members is the many-to-many between organizations and users, and it has no surrogate id — its identity is the pair of foreign keys, declared as a composite primary key in the second-argument callback. Both edges cascade: a membership is meaningless once either its organization or its user is gone.

src/db/schema.ts (continued)
export const orgMembers = pgTable(
'org_members',
{
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: memberRole().notNull(),
...timestamps,
},
(t) => [primaryKey({ columns: [t.organizationId, t.userId] })],
);
export type OrgMember = typeof orgMembers.$inferSelect;
export type NewOrgMember = typeof orgMembers.$inferInsert;

customers carries the tenant rule for the first time. organizationId is a NOT NULL foreign key that cascades, because a customer belongs to exactly one organization. The unique covers (organizationId, email), not email alone: an email is unique within a tenant. Two tenants can each have a billing@acme.test; the same tenant cannot.

src/db/schema.ts (continued)
export const customers = pgTable(
'customers',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
name: text().notNull(),
email: text().notNull(),
...timestamps,
},
(t) => [unique('customers_org_email_unique').on(t.organizationId, t.email)],
);
export type Customer = typeof customers.$inferSelect;
export type NewCustomer = typeof customers.$inferInsert;

invoices carries every feature at once: all three kinds of foreign key, the enum-backed status, two money columns, the tenant-scoped unique, a check constraint, and three indexes.

export const invoices = pgTable(
'invoices',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
customerId: uuid()
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
createdBy: uuid()
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
number: text().notNull(),
status: invoiceStatus().notNull().default('draft'),
total: numeric({ precision: 12, scale: 2 }).notNull(),
currency: text().notNull().default('USD'),
issuedAt: timestamp({ withTimezone: true }).notNull(),
dueAt: timestamp({ withTimezone: true }).notNull(),
...timestamps,
},
(t) => [
unique('invoices_org_number_unique').on(t.organizationId, t.number),
check('invoices_total_nonneg', sql`${t.total} >= 0`),
index('idx_invoices_org_status_created_at_id').on(
t.organizationId,
t.status,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_org_created_at_id').on(
t.organizationId,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_customer_id').on(t.customerId),
],
);
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;

Three .references() edges, each with its own onDelete. The org owns the invoice, so organizationId cascades. The customer and the author are entities the invoice only points at, so customerId and createdBy restrict: deleting a customer who still has invoices is refused, because a dangling reference is corruption.

export const invoices = pgTable(
'invoices',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
customerId: uuid()
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
createdBy: uuid()
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
number: text().notNull(),
status: invoiceStatus().notNull().default('draft'),
total: numeric({ precision: 12, scale: 2 }).notNull(),
currency: text().notNull().default('USD'),
issuedAt: timestamp({ withTimezone: true }).notNull(),
dueAt: timestamp({ withTimezone: true }).notNull(),
...timestamps,
},
(t) => [
unique('invoices_org_number_unique').on(t.organizationId, t.number),
check('invoices_total_nonneg', sql`${t.total} >= 0`),
index('idx_invoices_org_status_created_at_id').on(
t.organizationId,
t.status,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_org_created_at_id').on(
t.organizationId,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_customer_id').on(t.customerId),
],
);
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;

The enum makes the database reject any status outside the four allowed values, and a new invoice starts as 'draft'.

export const invoices = pgTable(
'invoices',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
customerId: uuid()
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
createdBy: uuid()
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
number: text().notNull(),
status: invoiceStatus().notNull().default('draft'),
total: numeric({ precision: 12, scale: 2 }).notNull(),
currency: text().notNull().default('USD'),
issuedAt: timestamp({ withTimezone: true }).notNull(),
dueAt: timestamp({ withTimezone: true }).notNull(),
...timestamps,
},
(t) => [
unique('invoices_org_number_unique').on(t.organizationId, t.number),
check('invoices_total_nonneg', sql`${t.total} >= 0`),
index('idx_invoices_org_status_created_at_id').on(
t.organizationId,
t.status,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_org_created_at_id').on(
t.organizationId,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_customer_id').on(t.customerId),
],
);
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;

numeric({ precision: 12, scale: 2 }) for total, a 'USD' default for currency. Fixed-precision decimal, never a float, so cents never drift.

export const invoices = pgTable(
'invoices',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
customerId: uuid()
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
createdBy: uuid()
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
number: text().notNull(),
status: invoiceStatus().notNull().default('draft'),
total: numeric({ precision: 12, scale: 2 }).notNull(),
currency: text().notNull().default('USD'),
issuedAt: timestamp({ withTimezone: true }).notNull(),
dueAt: timestamp({ withTimezone: true }).notNull(),
...timestamps,
},
(t) => [
unique('invoices_org_number_unique').on(t.organizationId, t.number),
check('invoices_total_nonneg', sql`${t.total} >= 0`),
index('idx_invoices_org_status_created_at_id').on(
t.organizationId,
t.status,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_org_created_at_id').on(
t.organizationId,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_customer_id').on(t.customerId),
],
);
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;

unique('invoices_org_number_unique') scopes the number to (organizationId, number) — unique per tenant, like customers.

export const invoices = pgTable(
'invoices',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
customerId: uuid()
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
createdBy: uuid()
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
number: text().notNull(),
status: invoiceStatus().notNull().default('draft'),
total: numeric({ precision: 12, scale: 2 }).notNull(),
currency: text().notNull().default('USD'),
issuedAt: timestamp({ withTimezone: true }).notNull(),
dueAt: timestamp({ withTimezone: true }).notNull(),
...timestamps,
},
(t) => [
unique('invoices_org_number_unique').on(t.organizationId, t.number),
check('invoices_total_nonneg', sql`${t.total} >= 0`),
index('idx_invoices_org_status_created_at_id').on(
t.organizationId,
t.status,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_org_created_at_id').on(
t.organizationId,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_customer_id').on(t.customerId),
],
);
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;

The check refuses a negative total at the database level, so the invariant holds whatever code path writes the row.

export const invoices = pgTable(
'invoices',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
organizationId: uuid()
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
customerId: uuid()
.notNull()
.references(() => customers.id, { onDelete: 'restrict' }),
createdBy: uuid()
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
number: text().notNull(),
status: invoiceStatus().notNull().default('draft'),
total: numeric({ precision: 12, scale: 2 }).notNull(),
currency: text().notNull().default('USD'),
issuedAt: timestamp({ withTimezone: true }).notNull(),
dueAt: timestamp({ withTimezone: true }).notNull(),
...timestamps,
},
(t) => [
unique('invoices_org_number_unique').on(t.organizationId, t.number),
check('invoices_total_nonneg', sql`${t.total} >= 0`),
index('idx_invoices_org_status_created_at_id').on(
t.organizationId,
t.status,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_org_created_at_id').on(
t.organizationId,
t.createdAt.desc(),
t.id.desc(),
),
index('idx_invoices_customer_id').on(t.customerId),
],
);
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;

The composite indexes mirror the list query’s where + orderBy: tenant first, then the keyset (createdAt, id) descending. idx_invoices_customer_id serves the detail join. Whether the planner uses them is proven against a live EXPLAIN plan next lesson.

1 / 1

Decide each ON DELETE rather than defaulting them all: owned children (the four cascading edges above) cascade, referenced entities (customerId, createdBy) restrict.

The last table, invoice_lines, closes the chain. Each line cascades from its invoice, and (invoiceId, position) is unique, so two lines can never claim the same slot.

src/db/schema.ts (continued)
export const invoiceLines = pgTable(
'invoice_lines',
{
id: uuid().primaryKey().default(sql`uuidv7()`),
invoiceId: uuid()
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
description: text().notNull(),
quantity: numeric({ precision: 12, scale: 2 }).notNull(),
unitPrice: numeric({ precision: 12, scale: 2 }).notNull(),
position: integer().notNull(),
...timestamps,
},
(t) => [
unique('invoice_lines_invoice_position_unique').on(t.invoiceId, t.position),
],
);
export type InvoiceLine = typeof invoiceLines.$inferSelect;
export type NewInvoiceLine = typeof invoiceLines.$inferInsert;

The constraints above teach Postgres the foreign keys. This file teaches Drizzle’s relational query API the same edges, so a nested read like db.query.invoices.findFirst({ with: { customer: true, lines: true } }) knows how to assemble itself. You declare a relations() per table, pointing each edge’s fields/references at the columns that join.

The one edge that needs care is invoices to users. An invoice touches users twice: through its organization’s members, and through its author in createdBy. Undisambiguated, a nested with cannot tell the two apart. A matching relationName: 'createdByUser' on both sides of the author edge — usersRelations.invoices and invoicesRelations.createdByUser — pins them as one named relationship, separate from membership.

src/db/relations.ts
import { relations } from 'drizzle-orm';
import {
customers,
invoiceLines,
invoices,
organizations,
orgMembers,
users,
} from '@/db/schema';
export const organizationsRelations = relations(organizations, ({ many }) => ({
members: many(orgMembers),
customers: many(customers),
invoices: many(invoices),
}));
export const usersRelations = relations(users, ({ many }) => ({
members: many(orgMembers),
invoices: many(invoices, { relationName: 'createdByUser' }),
}));
export const orgMembersRelations = relations(orgMembers, ({ one }) => ({
organization: one(organizations, {
fields: [orgMembers.organizationId],
references: [organizations.id],
}),
user: one(users, {
fields: [orgMembers.userId],
references: [users.id],
}),
}));
export const customersRelations = relations(customers, ({ one, many }) => ({
organization: one(organizations, {
fields: [customers.organizationId],
references: [organizations.id],
}),
invoices: many(invoices),
}));
export const invoicesRelations = relations(invoices, ({ one, many }) => ({
organization: one(organizations, {
fields: [invoices.organizationId],
references: [organizations.id],
}),
customer: one(customers, {
fields: [invoices.customerId],
references: [customers.id],
}),
createdByUser: one(users, {
relationName: 'createdByUser',
fields: [invoices.createdBy],
references: [users.id],
}),
lines: many(invoiceLines),
}));
export const invoiceLinesRelations = relations(invoiceLines, ({ one }) => ({
invoice: one(invoices, {
fields: [invoiceLines.invoiceId],
references: [invoices.id],
}),
}));

These consts are not a separate drizzle() option. The provided db/index.ts spreads them into the schema object — drizzle(client, { schema: { ...tables, ...relations } }) — which is what wires up db.query.<table> with the relation graph.

Generate the migration with a name so the file reads as intent, not a hash:

Terminal
pnpm db:generate --name init_schema

That writes drizzle/0000_init_schema.sql. Open it first. The structure is predictable: two CREATE TYPE ... AS ENUM, six CREATE TABLE, a block of ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY, then three CREATE INDEX. The part worth reading closely is the invoices table with its foreign keys and indexes:

drizzle/0000_init_schema.sql (excerpt)
CREATE TABLE "invoices" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"organization_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"created_by" uuid NOT NULL,
"number" text NOT NULL,
"status" "invoice_status" DEFAULT 'draft' NOT NULL,
"total" numeric(12, 2) NOT NULL,
"currency" text DEFAULT 'USD' NOT NULL,
"issued_at" timestamp with time zone NOT NULL,
"due_at" timestamp with time zone NOT NULL,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "invoices_org_number_unique" UNIQUE("organization_id","number"),
CONSTRAINT "invoices_total_nonneg" CHECK ("invoices"."total" >= 0)
);
--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_invoices_org_status_created_at_id" ON "invoices" USING btree ("organization_id","status","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "idx_invoices_org_created_at_id" ON "invoices" USING btree ("organization_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "idx_invoices_customer_id" ON "invoices" USING btree ("customer_id");

Your per-edge choices are now DDL: ON DELETE cascade on the organization edge, ON DELETE restrict on customer and author, and DESC NULLS LAST on the index columns so the planner walks them in the direction the list query reads. This file is the diff a teammate reviews in the pull request, the last checkpoint before DDL hits a database.

Once it matches your intent, apply it:

Terminal
pnpm db:migrate

That runs the file against the database and records it in the drizzle.__drizzle_migrations ledger. One file applied once leaves one row.

Requirement 1 asserts exactly one row in the migration ledger, which only holds on a database migrated once from empty. So start clean: docker compose down -v && docker compose up -d discards the volume and brings back an empty Postgres, then run pnpm db:migrate once.

With the database freshly migrated, run the tests:

Terminal
pnpm test:lesson 3

The suite introspects the live database through information_schema and pg_catalog, asserting real Postgres state, not your source files. Both requirement groups should pass:

Expected output
tests/lessons/Lesson 3.test.ts (7)
Requirement 1 clean migrate leaves exactly one migration row (1)
records exactly one applied migration in __drizzle_migrations
Requirement 2 six tables with their FKs, uniques, check, and indexes (6)
creates exactly the six tables in the public schema
points every foreign key at the right ON DELETE action
scopes the uniqueness constraints to the tenant where the domain demands
enforces the non-negative invoice total with a check constraint
creates the three query-justified indexes with the right columns and DESC ordering
Test Files 1 passed (1)
Tests 7 passed (7)

The suite covers requirements 1 and 2; confirm the rest by hand, ticking each as you go:

A second pnpm db:generate immediately after migrating reports no changes — the schema and the stored snapshot agree (requirement 3).
untested
pnpm db:studio shows all six tables, and each invoices foreign key, the tenant-scoped uniques, the total >= 0 check, and the three indexes are present (requirement 2, seen with your own eyes in Studio).
untested
Reading drizzle/0000_init_schema.sql, each foreign key carries its intended ON DELETE (cascade for owned children, restrict for the customer and author edges), each tenant-scoped unique covers the right columns, the check guards total >= 0, and the three indexes carry DESC NULLS LAST on their keyset columns.
untested
The relations resolve the two invoices → users edges distinctly — the author edge tagged relationName: 'createdByUser' on both sides — so a nested read of an invoice’s author and its org’s members does not cross-wire (requirement 4).
untested