On 26 September 2026, my Payload dev server hung on every request because of a question nobody could see. The trigger was one schema change: a group was dropped from a block, and new fields were added. My notes from that day explain what was going on underneath. Drizzle push, running inside the dev server, was waiting for an answer to a rename prompt, and for as long as that prompt waited, every request was held with it.
That prompt was nothing new in this project. On 15 September, push and migrate:create both asked whether a column had been created or renamed, so the local schema that day was applied by hand through psql and the question was answered with expect. A note from 17 September already describes a similar symptom: the dev server hangs on an interactive drizzle prompt when a column is moved while it runs.
The project runs Payload 3.88.0 and @payloadcms/db-postgres 3.88.0 on Next.js 16.3.0, with a lockfile pinning @payloadcms/drizzle 3.88.0, drizzle-kit 0.31.7, and drizzle-orm 0.45.2. According to the repo's README, a local database takes schema changes by push while you work, and a hosted database only ever takes migrations. When Payload decides to push, and what happens when that push reaches a hosted database, I have written about separately. This piece is about the laptop side: what kind of change raises the question, why one question can hold the whole dev server, and what I did afterwards.
One group dropped, three localized fields added
The block that changed used to hold a group with three localized fields and one array. The 26 September change dropped that group and put three new localized fields directly on the block. With the names replaced, it looked roughly like this:
// simplified, neutral identifiers, other fields on this block not shown
// before: localized fields live inside a group (the group's array is not shown either)
{
slug: 'featureGrid',
fields: [
{
name: 'callout',
type: 'group',
fields: [
{ name: 'kicker', localized: true, type: 'text' },
{ name: 'title', localized: true, type: 'text' },
{ name: 'body', localized: true, type: 'textarea' },
],
},
],
}
// columns in pages_blocks_feature_grid_locales: callout_kicker, callout_title, callout_body
// after: group dropped, three new localized fields on the block itself
{
slug: 'featureGrid',
fields: [
{ name: 'kicker', localized: true, type: 'text' },
{ name: 'title', localized: true, type: 'text' },
{ name: 'summary', localized: true, type: 'textarea' },
],
}
// columns in pages_blocks_feature_grid_locales: kicker, title, summaryIn Postgres, a block's localized fields live in that block's locales table, and fields inside a group carry the group's name as a column prefix. The two migrations written for this change show both states of that table. The down part of the first migration shows the old locales table holding three columns prefixed with the group's name, while the second migration creates a table with exactly the same name, holding three columns without the prefix. So within one table whose name never changed, three columns disappeared and three new ones appeared at the same time.
When drizzle-kit asks create or rename
The question is not asked for every column change. In the installed drizzle-kit 0.31.7, the function that handles column conflicts returns without asking when a table has only new columns, or only missing ones:
// drizzle-kit 0.31.7, api.mjs (trimmed)
promptColumnsConflicts = async (tableName, newColumns, missingColumns) => {
if (newColumns.length === 0 || missingColumns.length === 0) {
return { created: newColumns, renamed: [], deleted: missingColumns };
}
// ...
const promptData = [created, ...renames];
// ...
// select prompt state, in the same file
var SelectState3 = class {
constructor(items) {
this.items = items;
this.selectedIdx = 0;
}
// ...The question only comes up when a table has both at once, and the locales table above is exactly that. The list of choices puts the create option for the new column first, followed by one rename option for each missing column. With three old columns missing, one question can carry up to four choices, one create plus three renames. The option highlighted when the prompt first appears is index 0, and index 0 is create.
The text the dev server showed that day was never captured. What I can show is the template in the installed drizzle-kit code:
# template in drizzle-kit 0.31.7 api.mjs, not captured output
Is <column> column in <table> table created or renamed from another column?
+ <title> create column
~ <title> rename columnThe created or renamed fragment of this template is what later served as the marker in an expect script, which comes back near the end of this piece.
Why every request waits
A prompt waiting for an answer does not by itself explain why every request stalled, not just the ones touching that block. The explanation lies in how Payload 3.88.0 reloads itself during development. I assembled the chain below from the installed code, not from a trace captured while the dev server was stuck.
Payload listens to Next's HMR websocket, and as soon as a serverComponentChanges message arrives, getPayload marks its instance for reload. The reload function then reconnects the database through payload.db.connect({ hotReload: true }). While that reload runs, every later call to getPayload waits on the first reload's promise:
// payload 3.88.0, dist/index.js (trimmed, arranged in flow order)
// 1. an HMR message from Next marks the instance for reload
if (// On Next.js 15, we need to check for data.action. On Next.js 16, we need to check for data.type.
data.type === 'serverComponentChanges' || data.action === 'serverComponentChanges') {
cached.reload = true;
}
// 2. inside reload, the database is reconnected
await payload.db.connect({
hotReload: true
});
// 3. other getPayload calls wait for the first reload
if (cached.reload instanceof Promise) {
await cached.reload;
}In the Postgres adapter, connect runs the dev schema push as long as three conditions hold: NODE_ENV is not production, PAYLOAD_MIGRATING is not true, and the push option is not false. hotReload is not one of them, so the reconnect during a reload runs the push too. The push function in @payloadcms/drizzle carries its own comment saying the step will prompt if Drizzle needs clarification.
If the config change reaches Payload through this HMR path, the chain closes. The reload reconnects the database, the connection runs the push, the push stops at a create-or-rename question nobody can see, and the reload promise never settles. Every request that calls getPayload then waits on that same promise. The chain matches two of my notes: the dev server hangs when a column is moved while it runs, and on 26 September every request hung.
The way out that day
The way out did not try to answer the prompt. The dev server was stopped, the SQL from the change's two migrations was applied to the local database with psql, and the dev server was started again. The same recipe had been written down nine days earlier in a shorter form: move columns with psql first, or restart the dev server.
Read against the code, that order makes sense. Once the SQL from both migrations is in, the local locales table already has its new shape, so when push runs again after the restart, that table no longer loses and gains columns at the same time.
On 27 September a different case took a different route. Some columns in the local database were converted by running their migration's up once with PAYLOAD_DB_PUSH=0. In this project's config, push is on when that variable is 1, or when it is not 0 and the database is local. A value of 0 makes the push option false for that run, and the condition in connect above skips the push entirely.
Splitting the migration so drizzle never asks
For the 26 September change itself, the migration was deliberately split in two, one dropping the old group's tables and one adding the new fields, so that drizzle would never ask about a rename. The commit message gives the same reason. The first migration drops three tables, including that block's locales table, and the second creates the same locales table again with its new columns:
-- simplified, neutral identifiers, other columns and tables not shown
-- migration 1: drops only
DROP TABLE "pages_blocks_feature_grid_locales" CASCADE;
-- migration 2: creates only
CREATE TABLE "pages_blocks_feature_grid_locales" (
"kicker" varchar,
"title" varchar,
"summary" varchar
);
-- for comparison, from 15 September, when a column's contents had to survive
ALTER TABLE "entries" RENAME COLUMN "legacy_ref" TO "ref";Under the drizzle-kit rule above, this split closes the path to the prompt. The first step only drops and the second only creates, so in neither step does a table lose and gain columns at once.
The cost deserves a mention. DROP TABLE throws away the old table's contents rather than moving them, and the note written before that day's push says plainly that the block's migrations drop the old panel tables. For this change, that was the intent. For a rename whose contents must survive, this route does not work. On 15 September, an ID field tied to a single provider was replaced by a provider field with two choices and a general ID field, and the migration renamed the old column so the one real identifier already on staging would survive. The RENAME COLUMN line at the end of the block above is a neutral version of the statement in that migration file.
The same prompt outside the dev server
migrate:create asks create or rename as well, and on 29 September I noted that its rename prompt cannot be answered through a pipe. An expect script took the pipe's place, one that runs pnpm payload migrate:create <name> and sends Enter as soon as the text created or renamed appears:
# simplified, neutral migration name
spawn pnpm payload migrate:create migration_name
expect "created or renamed"
send "\r"
expect eofA day later this became the rule for migrate:create and for local seeds that raise Drizzle's rename prompt: answer Enter at "created or renamed", and y at the data loss question.
That Enter is not a neutral answer. In a select prompt, Enter submits the highlighted option, and as the code above shows, that option is create. For the 26 September change, where the old group's contents were meant to go, create was the intended answer. For a rename like the one on 15 September, the same Enter would create a new column instead of renaming the old one.
Pipes have another trap. migrate:create once exited silently without producing a file when run with yes n |, so the same note says to use < /dev/null and then check the migration file. When no column reads as a rename, pnpm payload migrate:create <name> runs without the create-or-rename question, which fits the drizzle-kit rule above.
Local seeds have their own version. The 29 September note says that dropping a column from the schema, through a seed or through dev, raises a Drizzle prompt that holds the dev server. The recipe there is to stop dev, run the seed with yes y |, then start dev again.
Payload's push function also has a second prompt, separate from drizzle-kit's rename question. When drizzle-kit returns warnings, pushDevSchema asks Accept warnings and push schema to database? with no as the default answer, and that confirmation calls process.exit(0) both when it is cancelled and when it is declined. A note from 27 September records what this does in a non-interactive run: push mode calls process.exit(0) on a schema warning. Exit code 0 reads as success to whatever started the process.
On 6 October I noted one more variation: payload migrate:create waits on a prompt when the schema has not changed.
The limits of what I can claim
The 26 September way out was carried out, but my notes contain no explicit check that requests were served again after the restart, and they record no permanent fix for the prompt's behavior. The prevention put in place is the split migration. The local push configuration line was not changed along the way, and it reads the same in the 15 September commit and in the 26 September commit that carried the block change.
The commit with the block change went out in the same day's push. Before that push, the project was built locally, 173 tests ran, lint reported zero errors, the staging database was read, and all 48 migrations were rehearsed on an empty database, with a resulting schema equal to the local one built by push: 1275 columns, 83 enums, and 756 indexes. That rehearsal proves the migrations and the local schema agree. It does not prove the hang is gone, and similar prompts did show up again on 27 September, 29 September, and 6 October.
One more thing I do not have is the prompt text the dev server actually showed that day. What I quoted above is the template from the installed drizzle-kit code, not captured output.
A question nobody can answer from inside a server
An interactive prompt assumes someone is sitting at the terminal. A push inside the dev server has nobody there, and since every getPayload waits on the same reload, one unseen question is enough to hold every request. The question itself only arises when a single table loses and gains columns at once. That is why dropping a group and adding replacement fields deserves to be treated as a database change, not just a config edit.
These notes leave a few habits worth reusing. Move columns with psql first, or stop the dev server, apply the migration SQL, and start it again. Split the migration into a drop step and a create step when the old contents are allowed to go, and keep a RENAME COLUMN when they must survive. If the rename prompt cannot be avoided, expect can answer it, as long as you remember that Enter means create. When migrate:create runs without a terminal, check that the migration file actually exists.