This is the whole deploy pipeline for PubTrivia's Next.js half:
"build": "drizzle-kit migrate && next build"
A hosted build runs that. So the order of events on a deploy is: apply every
pending migration to the database, then compile the app, then swap the running
version. Three things in a sequence, and the schema moves first.
I want to lay out what that ordering actually buys and costs, because "run
migrations in the build" is one of those decisions that is either obviously fine
or obviously reckless depending entirely on what is in the migration.
The order is correct for additive changes
Here is migration 0009, in full:
ALTER TABLE "venues" ADD COLUMN "plan_type" text DEFAULT 'trial' NOT NULL;
Adding a column with a default is invisible to code that does not select it. The
build applies it, the old version keeps serving requests for the two or three
minutes the compile takes, and it never notices the column exists. Then the new
version goes live and the column is already there. If the order were reversed,
compile and deploy first and migrate after, there would be a window where new
code selects a column that does not exist yet, which is a 500 on every request
that touches venues.
For additive migrations, migrate-then-build is the ordering that has no window
at all. That is the case for 13 of the 14 files here: the only other statement with the word DROP in it is a DROP NOT NULL, which relaxes a constraint rather than removing anything.
And wrong for the one destructive change
Migration 0012 is also one statement:
DROP TABLE "user_sessions" CASCADE;
Apply that in the build step and the currently running version, the one still
serving traffic while the new bundle compiles, loses a table it may be querying.
The window is short and the blast radius depends on what the old code does with
it, but it is a real window, and "the build was slow that day" is not a thing I
want in the failure path.
So the honest rule the pipeline encodes is: this ordering is safe as long as
every migration is additive. That is a property of migrations, not of pipelines,
and nothing in package.json can check it. The pipeline is a bet on the
discipline of whoever generated the file.
What I actually do now is generate the SQL, read it, and apply it deliberately,
rather than letting a deploy be the first thing that runs it. The next change to
this repo's tooling is taking drizzle-kit migrate out of build and giving it
its own step, so that a destructive migration is something a human has to
schedule rather than something a push performs.
A table created and dropped eighteen minutes apart
The journal in meta/_journal.json carries a timestamp per migration, and two
of them are worth putting side by side:
11 2026-06-18T11:13:12 0011_add_venue_members_user_sessions
12 2026-06-18T11:31:38 0012_marvelous_mad_thinker
0011 creates venue_members and user_sessions. 0012, eighteen minutes later,
drops user_sessions. Then three months pass and 0013 creates it again, with a
different shape: a hashed session column with a unique index on it, and an index
on the user id.
Nothing is wrong with that history and it cannot be tidied. A migration directory
is an append-only log of what was done to a real database, not a description of
the schema, so the 0012 that undoes 0011 has to stay even though the net effect
of running all three is "the table exists, in its third design". The schema is
utils/db/schema.ts. The migrations are the diary.
This is also the answer to the question people ask when they first see a
directory like this: can I squash them? Only if no database anywhere is partway
through the list. Mine is not partway through, but the answer still ends up being
no, because the squash saves nothing and costs the one thing the files are for.
wc -l says five of them are empty
$ wc -l utils/db/migrations/*.sql
8 0000_colossal_kree.sql
106 0001_silent_solo.sql
1 0002_old_alice.sql
0 0003_swift_zzzax.sql
1 0004_tiny_the_watchers.sql
5 0005_tough_ink.sql
2 0006_concurrent_load_hardening.sql
0 0007_nostalgic_ben_parker.sql
10 0008_plain_outlaw_kid.sql
0 0009_conscious_hellcat.sql
0 0010_funny_ultragirl.sql
30 0011_add_venue_members_user_sessions.sql
0 0012_marvelous_mad_thinker.sql
11 0013_right_joseph.sql
Five zeroes, and not one of those files is empty. 0009 is the ALTER TABLE
quoted above; 0012 is the DROP TABLE. They are single statements written
without a trailing newline, and wc -l counts newline characters, not lines. The
generator emits exactly what it needs and stops, so a one statement migration has
zero newlines in it.
I would not have noticed if I had not been counting migrations by piping them
through wc, which is a decent reminder that the first number any audit produces
is usually a property of the measuring tool. The real total is 14 files, 65
statements, 187 lines: 29 ALTER TABLE, 15 CREATE INDEX, 13 CREATE TABLE, 7
CREATE UNIQUE INDEX, 1 DROP TABLE.
The 374 KB nobody reads
Beside those 12 KB of SQL sits meta/, which is 15 JSON files and 374 KB:
14313 lines across meta/*.json
A snapshot per migration plus the journal. It is thirty times the size of the SQL
it describes, and it has to be committed, because drizzle-kit generate diffs
your TypeScript schema against the last snapshot rather than against the
database. Delete the snapshots and the next generate produces a migration that
recreates the world. Commit them and your diffs stay honest on a machine that has
never seen your production database.
That is the part of this setup I would defend without hesitation. The part I am
changing is the &&.
The parts a reader can see
Three of the tables in this list are features you can look at without taking my
word for the SQL. venue_members from 0011 is what
multiple hosts and venues
describes: a row per host invited to a venue, with a status that starts as
pending. The plan_type column from 0009 defaults to trial, which is the trial
described on the pricing page, free tier and no
card. And the tables 0001 brought in are what
session history reads back
after a quiz night is over.
Fourteen files, 187 lines, four months, one of them undoing another. For a
schema this small, the interesting artefact was never the SQL. It was the && in
the build script, which is a deployment policy disguised as a shell operator.
Top comments (0)