← Blog

Shipping a Drizzle schema change when the deploy does not run migrations

When the deploy does not run migrations, order matters: apply the additive Drizzle migration first, then ship the code that uses it. A real rollout.

In the kit, npm run build is next build and nothing else. No step of the deploy touches the database: push to main, the host builds, and a few minutes later the new code is serving traffic against whatever schema production already has. That makes every schema change two separate events. The database changes when someone runs a command; the code changes when the deploy lands. Which one goes first decides whether your users see errors in between.

This is the order the kit follows, told through the change that made it matter: adding a billing_customers table on 2026-10-14 so checkout could reuse a user's Stripe customer.

What breaks when the code lands first

The new table sits on the paid path. The checkout route's first database call is getStripeCustomerId, and its first query reads the new table:

export async function getStripeCustomerId(userId: string): Promise<string | null> {
  if (!hasDatabase()) return null;
  const [saved] = await db()
    .select({ customerId: billingCustomers.stripeCustomerId })
    .from(billingCustomers)
    .where(eq(billingCustomers.userId, userId))
    .limit(1);
  if (saved) return saved.customerId;
  // ...then falls back to the subscriptions table (below)
}

Deploy that before the table exists and Postgres answers relation "billing_customers" does not exist. Nothing catches it, so every signed-in user who clicks Upgrade gets a 500 until someone creates the table. That is not a degraded mode — the one route that takes money is down.

The reverse is harmless. Old code never mentions billing_customers, so it runs fine against a database with an extra table in it. An additive change is always safe in that direction, which gives the whole rule: the database goes first, then the code that needs it.

Generate the migration and read it

Change src/lib/db/schema.ts, then:

npm run db:generate   # drizzle-kit generate

drizzle-kit diffs the schema against the last snapshot in drizzle/meta/ and writes plain SQL, plus a new snapshot and an entry in drizzle/meta/_journal.json. For this change it produced drizzle/0001_burly_baron_strucker.sql:

CREATE TABLE "billing_customers" (
  "user_id" text PRIMARY KEY NOT NULL,
  "stripe_customer_id" text NOT NULL,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  CONSTRAINT "billing_customers_stripe_customer_id_unique" UNIQUE("stripe_customer_id")
);
--> statement-breakpoint
ALTER TABLE "billing_customers" ADD CONSTRAINT "billing_customers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

Read it before going further, checking one thing: does every statement only add? CREATE TABLE, a new nullable column, a new index — additive. DROP, RENAME, SET NOT NULL or a type change — not additive, and the ordering changes (see below). The --> statement-breakpoint lines are where Drizzle's migrator splits the file to run one statement at a time; they start with --, so to Postgres they are comments and the file pastes into a SQL console as-is.

Apply it to production, check, then push

There are three ways to get it into the production database, all run by you rather than by the deploy:

  • npm run db:push with production's DATABASE_URL. drizzle.config.ts loads .env.local through dotenv, which never overwrites a variable that is already set, so an inline DATABASE_URL=... npm run db:push wins over the file.
  • Paste the generated file into your provider's SQL console.
  • npx drizzle-kit migrate — but only if production's migration history is in order (see the trap below).

Then confirm it landed (\d billing_customers in psql, or the console's table list), and only then push the code.

On 2026-10-14 that check is what held the push. Before deploying, we sent an unauthenticated POST to production's /api/billing/checkout. The route checks hasStripe() before it checks the session, so a 503 would have meant billing was not configured and the new query unreachable. It returned 401 ("Sign in to upgrade."): Stripe was live, so any signed-in user would hit the new query. The commit stayed on a local branch rather than main — anything else pushing to main would have deployed it — until the table had been created in the database console. Then the branch went out. No checkout ever ran against a missing table.

db:push vs migration files

The kit's package.json has two database scripts, and they work differently.

db:push (drizzle-kit push) connects to the database, introspects what is there, diffs it against schema.ts and applies the difference directly. No file, no history — which is why it is the fast path for a new project with one database. It does stop to ask when the diff is ambiguous or destructive. A renamed column shows up as "Is x column created or renamed from another column?", and adding a NOT NULL column without a default to a table that has rows produces "You're about to add not-null x column without default value, which contains N items" — with delete from <table>; queued as the way through. Approve that and the table is empty. --force is documented as "Auto-approve all data loss statements", so never pass it against production; --strict asks before every push and --verbose prints each statement, so use both there. Against a database that already matches the schema, push reports "No changes detected" — a cheap way to confirm production is in sync.

db:generate plus drizzle-kit migrate is the reviewable path: every change is a committed SQL file, and migrate records what it has applied in a table, drizzle.__drizzle_migrations (schema drizzle; columns id, hash, created_at), then runs whatever is newer.

The trap when you switch from push to migrate

How "whatever is newer" is decided matters. Drizzle's Postgres migrator reads only the most recent row of that table, then runs every migration whose journal timestamp (when in _journal.json) is greater than that row's created_at, all inside one transaction. The hash is written but never compared.

Now take a production database built with db:push — the only script the kit ships that writes to the database — plus one table pasted in by hand, which is how billing_customers reached the kit's own production. __drizzle_migrations is empty, so migrate concludes nothing has run and starts at 0000. Its first statement, CREATE TABLE "account", fails with relation "account" already exists, the transaction rolls back, and the command exits 1. (Had 0000 gone in through migrate, the hand-pasted 0001 still would not be recorded, and the run would fail on CREATE TABLE "billing_customers" instead.) We reproduced this on a local Postgres 16 with drizzle-kit 0.31.10; in captured, non-interactive output the CLI printed little beyond its spinner, and the actual error was in the Postgres server log.

The fix is to baseline: record the migrations production already has, with the timestamps the journal uses. The schema and table definition mirror what the migrator creates (it also creates them on its first run, even a failed one):

CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
  id SERIAL PRIMARY KEY,
  hash text NOT NULL,
  created_at bigint
);
INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES
  ('<sha256 of drizzle/0000_sudden_joseph.sql>', 1781471718439),
  ('<sha256 of drizzle/0001_burly_baron_strucker.sql>', 1791991102622);

Get each hash with sha256sum on the file. After that, migrate is a no-op and the next generated file applies normally; a fresh, empty database still migrates from 0000 to all seven tables. Baseline only after confirming the live schema really matches those files (push --strict --verbose reporting no changes is that check) — otherwise you are writing down a history that never happened.

When the change is not additive: expand, then contract

Renames, drops and new required columns do not fit "database first", because old code breaks against the new schema. Split them across deploys so every intermediate state works with whatever code is live:

  1. Expand. Add the new shape alongside the old one — a new table, or a nullable column — and apply it as above.
  2. Deploy code that copes with both. getStripeCustomerId is a small example: after reading billing_customers it falls back to the customer id on the user's newest subscriptions row, because users who subscribed before the table existed only have it there.
  3. Backfill if old rows need the new shape, then add the constraint — SET NOT NULL once no nulls remain.
  4. Contract. Drop the old column or table in a later migration, after the code that stopped reading it is live.
  // Users who subscribed before billing_customers existed only have the id on
  // their subscription row.
  const [row] = await db()
    .select({ customerId: subscriptions.stripeCustomerId })
    .from(subscriptions)
    .where(eq(subscriptions.userId, userId))
    .orderBy(desc(subscriptions.updatedAt))
    .limit(1);
  return row?.customerId ?? null;

For removals the order flips — code first, then database — which is the same rule seen from the other side. While a deploy is in flight, the schema has to suit both the old code and the new: for an addition that means the database runs ahead of the code, for a removal it means the database lags behind.

Why the build does not migrate

Putting drizzle-kit migrate && next build in the build script handles the additive case on its own, since the migration finishes before the new deployment serves traffic. The kit still keeps it out, for three reasons. Removals need the opposite order, so the discipline does not go away. Every build that has a DATABASE_URL migrates — preview builds included, if your preview environment shares production's database — so an unmerged branch can change production. And a migration that succeeds followed by a build that fails leaves production changed with no code shipped to match. One explicit command, with a check before the push, is a small price for avoiding all three.

The checklist

  1. Edit schema.ts, run npm run db:generate, read the SQL.
  2. Additive? Apply it to production first. Not additive? Expand and contract over two or more deploys.
  3. Confirm production has it (\d table in psql, or push reporting "No changes detected").
  4. Push the code.
  5. Removing something? Ship the code that stops using it, then drop it.
  6. Moving an existing database from push to migrate? Baseline drizzle.__drizzle_migrations first.

The feature this rollout shipped is covered in Stripe Checkout and the customer portal in the App Router, and the database client it runs on in Postgres connection pooling on serverless. Shipwright — the Next.js and Claude starter kit this blog documents — ships the Drizzle schema, both migrations and the db:generate / db:push scripts, wired to the billing and usage tables they describe.