I set up this staging build command so the migrations run first: pnpm payload migrate && pnpm build. The commit that added it, in the small hours of 11 September 2026, gives the reason in its message: a schema change should land with the deploy that needs it. My staging notes put one rule next to it, in a single sentence: never rely on schema push in production.
The project runs Payload CMS 3.88.0 with the @payloadcms/db-postgres adapter at the same version, on Next.js 16.3.0. Staging is on Vercel, and the database is Postgres on Neon, installed through the Vercel Marketplace. The first migration is 20260910_163813_initial.ts.
Within a few days, that build command failed in two opposite ways. One deploy stopped at a question and waited for an answer from a terminal nobody was watching. Another finished green, went live, and then answered every request with a 500. Both began with a single row in the payload_migrations table, and Payload wrote that row itself.
Push is decided by NODE_ENV, not by the database address
Payload's Postgres adapter has two ways to bring a database in line with the config. Push syncs the tables straight from the config with no migration files, and it is meant for development. Migrate runs the migration files in order and records each one it has applied as a row in payload_migrations. A hosted database is supposed to change through the second path only.
In 3.88.0, the push decision is made when the adapter connects:
// Only push schema if not in production
if (process.env.NODE_ENV !== 'production' && process.env.PAYLOAD_MIGRATING !== 'true' && this.push !== false) {
await pushDevSchema(this);
}The comment says push only happens outside production, and production here means the value of NODE_ENV. The address of the database being connected to is never weighed. As long as the push option is not set to false and PAYLOAD_MIGRATING is not 'true', any process with a NODE_ENV other than production will push, including a script run from a laptop against a hosted database.
The seed was exactly that kind of script. It ran from my machine against Neon with push on, which is the default outside production, so Payload pushed the schema and wrote a row named dev with batch -1 into payload_migrations. My own records disagree on the date. My staging notes put the seed on 11 September, while the guard's commit message on 13 September says the row was left by the very first seed on 10 September. Both name the same row.
The row Payload writes itself
pushDevSchema in @payloadcms/drizzle 3.88.0 does not stop once the schema is applied. Its tail looks like this:
await apply();
const migrationsTable = adapter.schemaName ? `"${adapter.schemaName}"."payload_migrations"` : '"payload_migrations"';
const drizzle = adapter.drizzle;
const result = await adapter.execute({
drizzle,
raw: `SELECT * FROM ${migrationsTable} WHERE batch = '-1'`
});
const devPush = result.rows;
if (!devPush.length) {
// Use drizzle for insert so $defaultFn's are called
await drizzle.insert(adapter.tables.payload_migrations).values({
name: 'dev',
batch: -1
});
} else {
await adapter.execute({
drizzle,
raw: `UPDATE ${migrationsTable} SET updated_at = CURRENT_TIMESTAMP WHERE batch = '-1'`
});
}After the push is applied, the function looks for a row with batch -1. If there is none, it inserts a dev row at batch -1. If there is one, it only refreshes updated_at. So the row is not the leftover of one bad run. Every connect with push on makes sure it exists.
On a local database the row is normal and harmless. On a database that migrations manage, it silently turns payload migrate into a no-op.
One prompt, two failures
The next payload migrate on Vercel saw that row and stopped at "It looks like you've run Payload in dev mode... Would you like to proceed?", with nobody at the terminal. The branch that raises it lives in migrate.js from the same version:
if (migrationsInDB.find((m)=>m.batch === -1)) {
const { confirm: runMigrations } = await prompts({
name: 'confirm',
type: 'confirm',
initial: false,
message: "It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" + "If you'd like to run migrations, data loss will occur. Would you like to proceed?"
}, {
onCancel: ()=>{
process.exit(0);
}
});
if (!runMigrations) {
process.exit(0);
}One row with batch -1 is enough to raise this prompt, and the default answer is false. What it asks is whether to run the migrations. A cancelled prompt and an answer that does not confirm both end in process.exit(0), an exit code the shell reads as success.
The hung deployment was removed, and the 11 September build stayed live. The marker was found on 12 September. When the table was read that day it held 20260910_163813_initial at batch 1 and dev at batch -1, with 73 tables from the initial schema.
The second failure was worse because it did not look like one. On 13 September 2026 staging answered every request with a 500 while its build was green, and the cause was that dev row at batch -1. Nine migrations never reached the database, the deployment went live, and the first query died on a table that had never been created. Nobody found out until someone opened the page. My staging notes sum it up in one sentence: a green build is not a migrated database.
My notes do not record how the prompt ended in that build, nor why the earlier deploy hung while this one carried on. The green itself can be read from the build command. At the time it was still pnpm payload migrate && pnpm build, and && only runs the second command when the first exits with 0. pnpm build ran and the deployment went live, so migrate exited with 0 without applying a single migration. The code above provides a path for exactly that: any answer other than yes ends in process.exit(0).
Marker deleted, push follows the database address
The repair on 13 September was unremarkable. The marker row was deleted, the nine pending migrations were applied, and staging came back. The one initial migration already recorded plus those nine, 1 + 9 = 10, matches the ten of ten the guard later reported on staging.
The prompt itself warns that running migrations will cause data loss. In this case the table still held the initial schema when it was read on 12 September, but if your database was ever pushed with a schema that has drifted from your migration files, take that warning seriously before you delete the marker.
The upstream cause had already been closed the night before, at 23:41 on 12 September, in a commit titled "fix(db): schema push only for a local database". Push now follows the database address:
const databaseUrl = process.env.DATABASE_URL || ''
const localDatabase = /@(localhost|127\.0\.0\.1)[:/]/.test(databaseUrl)
push: process.env.PAYLOAD_DB_PUSH === '1' || (process.env.PAYLOAD_DB_PUSH !== '0' && localDatabase),An address containing localhost or 127.0.0.1 still pushes as before. Any other address gets push: false, and the adapter's this.push !== false condition skips the push entirely, whatever NODE_ENV says. Setting PAYLOAD_DB_PUSH to 0 or 1 overrides that decision for one run.
That commit decides when push may run, but it does not delete a row that has already been written. The marker on Neon was still the cause of the 500 the next day, and it only went away as part of the repair.
A guard between migrate and build
The same day, the build command gained a step in the middle, in a commit titled "fix(deploy): a build whose database is behind its code now fails":
- "buildCommand": "pnpm payload migrate && pnpm build",
+ "buildCommand": "pnpm payload migrate && PAYLOAD_DB_PUSH=0 pnpm payload run scripts/check-migrations.ts && pnpm build",scripts/check-migrations.ts takes the files in src/migrations whose names match ^\d{8}_\d{6}_.+\.ts$, reads the payload-migrations rows through the Local API, and prints how many migrations are on disk and how many are recorded in the database. Any migration on disk without a row is named one by one, with a note that the deployment would answer 500, and the build fails with exit code 1. A row with a negative batch gets the same treatment:
if (marker.length > 0) {
console.error(`\nThe database carries ${marker.length} development marker row(s): ${marker.map((r) => r.name).join(', ')}.`)
console.error('While one of these is present Payload treats the schema as push managed and applies no migrations.')
console.error('Delete it, then deploy again: delete from payload_migrations where batch < 0;')
process.exit(1)
}Testing this guard is also what exposed the nature of the marker. Run against a scratch database, the guard fired on a database that was perfectly healthy. A database managed by push always carries the dev row, so the guard declines to judge one. It reads the same switch the config reads, PAYLOAD_DB_PUSH, which defaults to on for a local address, and when push is on for that database it exits with 0 without checking anything. The build command pins PAYLOAD_DB_PUSH=0 so the deployed case is never ambiguous.
The guard's commit message lists four paths exercised against a scratch database carrying all ten migrations: a complete database passes, a planted marker fails and is named, a deleted migration row fails and is named, and a push managed database is left alone. After that the guard ran against staging and reported ten of ten recorded. By the end of 13 September everything was on origin/main, ending at the migration guard.
Later pushes, with Neon read first
The staging seed script, tools/seed-staging.sh, exports PAYLOAD_DB_PUSH=0, and before it runs its command it prints the last three rows of payload_migrations on Neon as name@batch.
My notes record a read of Neon before the pushes of 14, 18, 22, 24 and 26 September. Late on 14 September there was no dev marker row, and a push of twenty-three commits triggered a deployment through the GitHub integration that was Ready about ten minutes later. The build command applied four new migrations as batch 3, the guard let it through, and the old deployment kept answering 200 throughout.
The numbers in the pushes after that line up. On 18 September Neon had no marker and 16 of 27 migrations, and the build applied the remaining eleven: 16 + 11 = 27. On 22 September three migrations took it from 27 to 30, and on 24 September nine migrations went in as batch 7, from 30 to 39. In all three pushes the migrations were applied to an empty scratch database first, and the pre-flight read at midday on 26 September found no dev marker either.
On the laptop the behavior is the reverse, and that is correct. On 21 September payload migrate hung locally on the dev marker prompt, as it should, because the local schema comes from push.
Running migrations before the build has a price too: the database changes before anyone knows the build will pass. In the morning push of 15 September, the first deployment failed at the type check after applying two migrations as batch 4, and only the next two deployments were Ready. The rule I wrote down on 26 September answers that gap: before pushing, run pnpm build locally with the dev server stopped, because Vercel migrates Neon before it builds and a failed build would leave the old code on the new schema. That day's push only went out after a local build, 173 tests, lint with 0 errors, a rehearsal of all 48 migrations on an empty database whose schema matched the pushed local one (1275 columns, 83 enums, 756 indexes), and a read of Neon.
There is one thing I cannot claim yet. My notes do not record a single real build that this guard has failed since it went in. Its failure paths have only been proven on a scratch database, and as far as the record goes, it has only let through pushes whose migrations were complete.
A green build is not a migrated database
If your build command runs payload migrate ahead of the build, an exit code of 0 from that step only means the process ended without reporting an error. In 3.88.0, a dev mode prompt that is not answered yes also exits with 0. What actually tells you whether the database has caught up with the code is a comparison between the migration files and the rows in payload_migrations, and that comparison has to be allowed to fail the build.
A dev row at batch -1 on a local database is not damage but the trace of something that connected with push on. On a hosted database, the same trace tells you some process once connected to it with push on. Letting the database address decide push closes that path, but a marker that is already written stays until it is deleted, and until then payload migrate on a build machine will keep stopping at the same question.