Schema changes
Generate, review, and apply Drizzle migrations - the scripts, the db:deploy step every deploy runs, and the checks to make before narrowing a money column.
The schema files under packages/database/src/db/ are the source of truth. Local databases are pushed to match them; shared and production databases are shaped by migration files in packages/database/drizzle/, committed alongside the schema change that produced them.
Scripts
Run each with pnpm --filter @repo/database <script>.
| Script | What it does | Use it for |
|---|---|---|
setup | Pushes the schema straight to the database | Local development |
generate | Diffs the schema files and writes a migration into packages/database/drizzle/ | Every change bound for a shared or production database |
migrate | Applies pending migrations | Shared and production databases |
push | The same sync as setup, without the rest of it | Local iteration only |
studio | Opens Drizzle Studio to browse and edit tables | Inspecting data |
deploy | The production apply step - see below | Deploys (already wired in) |
reset | DESTRUCTIVE: drops and recreates the public schema | Local resets only |
Changing the schema
packages/database/src/db/schema.ts.pnpm --filter @repo/database generatepackages/database/drizzle/, then commit it alongside the schema change.pnpm --filter @repo/database migrateLocally, pnpm db:setup is enough - it pushes without writing a migration. Anything a teammate or a production database will see needs the file, so generate it before you ship.
The GenerateSaaS boilerplate repo itself is push-only: its own migration history never ships, so it iterates with db:setup. Your generated project is the one that keeps migrations.
What db:deploy runs
init prepends pnpm -F @repo/database run deploy to the production start script (or to the Vercel build), so it runs on every deploy.
| Mode | Steps |
|---|---|
| Default | Reconcile the baseline, drizzle-kit migrate, then verify |
--push (demo deploys) | drizzle-kit push --force, then verify |
Verify is the last step either way. It re-reads the live database and fails the boot naming any table or column that differs from the schema files - so an apply that reported success having changed nothing never reaches runtime.
Baseline reconciliation. When drizzle.__drizzle_migrations is empty and every table the baseline migration creates already exists, db:deploy records that baseline as applied instead of executing it - so a baseline generated against an already-pushed database never fails with relation already exists.
Projects generated on 3.3.0-3.4.1
Those releases shipped no drizzle/ directory, and a push shaped the database.
| Situation | What db:deploy does | What you do |
|---|---|---|
No drizzle/ directory | Warns once, falls back to push + verify | Run pnpm --filter @repo/database generate once and commit the baseline |
drizzle/ present but holds no .sql | Aborts: No migrations found in packages/database/drizzle | Run generate |
| Baseline present, no migration recorded yet, every table it creates already exists | Records the baseline as applied, runs only later migrations | Nothing |
| Baseline present, only some of its tables exist | Aborts and names the missing tables | Generate the baseline from the schema the database was pushed with, then generate again for the difference |
Generate the baseline before you merge a schema change from an update, so it matches the pushed database.
Before narrowing a money column
Postgres refuses a rewrite when a value does not fit the new type - it never truncates one silently, so no balance is ever quietly clipped. What you get is a bare error that names nothing:
ERROR: numeric field overflow (SQLSTATE 22003)
DETAIL: A field with precision 16, scale 6 must round to an absolute value less than 10^10.Run this first to find the offending rows yourself, adjusting the columns and the ceiling to the change you are making:
SELECT 'billing_logs' AS table_name, 'amount' AS column_name, count(*)
FROM billing_logs WHERE abs(amount) >= 10000000000
UNION ALL
SELECT 'users', 'credits', count(*) FROM users WHERE abs(credits) >= 10000000000;Settle, correct or write down every row it returns before deploying. Do not "fix" it by widening the ceiling back - a balance clipped to fit is money the ledger stops accounting for.
packages/database/src/db/auth.ts is generated by Better Auth - never edit it by hand. Add a column by declaring it in additionalFields inside packages/auth/src/config.ts, then run pnpm --filter @repo/database auth:generate. The CUSTOM-marked numeric credits columns and extra indexes are hand-applied tweaks - re-apply them after each regeneration. Never run reset against a production database.