Last updated on

When two migrations collide: parallel schema PRs and Payload's linear history


Part 3 of 8 in the series Running a production database, carefully. Full series ↓

I set out to add the most ordinary feature a blog can have, categories and tags, and ended up hand-reconciling my production database at midnight. Nothing about the feature was hard. What got me was everything around the migration: a preview build that quietly talks to production, and a second schema change landing in parallel that turned my tidy migration into a lie.

The setup

The blog runs Payload 3 on Postgres (Neon), deployed on Vercel. Adding taxonomies was textbook: two new collections (categories, tags), a relationship field on posts, a couple of archive routes, and revalidation hooks so the static pages refresh when an editor publishes. Tests passed, the build was green, the migration was clean. The PR opened feeling good.

Then the preview deployment failed.

Trap #1: your preview build is talking to production

The Vercel log was blunt:

error: relation "posts_rels" does not exist

Error: Failed to collect page data for /blog/[slug]

posts_rels is a table the migration creates. Why would a preview build care whether it exists? Because the preview build was running next build against the production database. Payload prerenders pages at build time by querying the database, and without something like Neon preview branching, "the database" is prod. The migration hadn't been applied to prod, so the build couldn't query the tables the new code expected — and it took down even the existing /blog/[slug] route, which now selected a column that wasn't there yet.

The fix is a specific ordering: apply the migration to production before you merge. For an additive migration this is safe — the currently-live code doesn't reference the new tables, so they just sit there unused until the new code ships. Run it, let the preview rebuild, and it goes green. (That ordering is a workaround, not a fix. The fix — giving every preview its own database branch — landed three days after this post: the preview database that was still production.)

So the database was migrated. Feeling good again. This is the part that comes back to bite.

Trap #2: two schema PRs, one linear history

While the PR sat in review, another one merged to main: an editorial-versioning feature that turned on Payload drafts (adding drafts was the easy part tells that side). It added a _status column to posts and a _posts_v "versions" table that mirrors the posts schema — every field on posts gets a shadow on _posts_v.

When main was merged back into the branch, Git resolved the file conflicts cleanly. Tests still passed. The build was still green. And the migration was now silently, invisibly wrong.

Here's why. Payload's Postgres migrations are a linear chain. Each migration ships with a .json snapshot of the full schema after it runs, and that snapshot is the diff base for the next migrate:create. The system assumes migration N was generated on top of migration N−1.

But the taxonomy migration and the versioning migration were siblings — both branched off the same commit, neither aware of the other — a fan-out whose Mermaid rendering later turned into its own debugging story:

The taxonomy migration was generated before _posts_v existed, so it never added the taxonomy columns to the versions table. After the merge, the real schema needed _posts_v.version_category_id and a _posts_v_rels join table for tags — and nothing created them. There was no conflict marker, no failing test, and no broken build to catch it. The gap only surfaces at runtime, the first time an editor saves a post with a category and Payload tries to write the shadow row.

"No merge conflicts" is not "correctly merged." Git reconciles text. It has no idea that two migrations both mutate the same database, or that a new collection needs to reach into a versions table it's never heard of.

The same class of gap hid in application code, too: the versioning PR required every public query to filter _status: 'published' so drafts don't leak onto the live site. The new getPostsByCategory / getPostsByTag helpers were written before that rule existed, so they'd have happily served unpublished drafts on the archive pages. Auto-merge combined the files without a murmur.

Fixing the chain

The correct move is to make the history linear again — regenerate the taxonomy migration on top of the versioning migration:

  1. Roll the old migration back on the local DB (payload migrate:down).
  2. Delete its .ts and .json.
  3. Run payload migrate:create again. Now it diffs against the versioning snapshot and produces a complete migration — base taxonomy tables plus version_category_id and _posts_v_rels.
  4. Re-apply locally and verify.

Now the migration is honest. But there was still a production database that had already run the old version of it.

Trap #3: the migration I'd already applied

Remember Trap #1? Production was already migrated to fix the preview build. So production now held:

  • the base taxonomy tables (categories, tags, posts_rels) — created by the old migration
  • not the _posts_v taxonomy columns — the old migration never made them
  • a payload_migrations record for a migration file that no longer existed in the repo

Running the regenerated migration against prod would blow up on CREATE TABLE "categories"relation already exists. The database and the migration history had diverged, and no amount of migrate was going to reconcile them automatically.

The saving grace: the tables were empty. No editor had assigned a category or tag yet. So it was safe to drop the half-applied state and let the corrected migration recreate it cleanly:

ALTER TABLE "posts" DROP COLUMN IF EXISTS "category_id";
DROP TABLE IF EXISTS "posts_rels" CASCADE;
DROP TABLE IF EXISTS "categories" CASCADE;
DROP TABLE IF EXISTS "tags" CASCADE;
DELETE FROM payload_migrations WHERE name = '20260805_213617_add_categories_tags';

Then a normal migrate applied the regenerated migration, and a quick SELECT ... to_regclass(...) confirmed prod finally matched the repo, _posts_v_rels and all.

Empty tables made this a five-minute cleanup. Had real taxonomy data existed, "drop and recreate" would have been off the table and a careful data-preserving patch migration would have been necessary instead. The window where migrating prod early is cheap to undo is exactly the window before anyone uses the feature — and that window closes quietly.

What I'd tell past-me

  • Know what your build queries. If preview deploys prerender against production, every schema-change PR has a deploy-ordering dependency whether it's been noticed or not. Migrate prod first — or better, give previews their own database (Neon branching, a seeded ephemeral DB) so the question disappears.
  • Migrating prod before merge is a bet that the migration won't change. Usually a safe bet. It stops being safe the moment a parallel PR forces regeneration. Migrate as late as you safely can.
  • Parallel schema PRs need a rebase discipline. A merge alone isn't enough. When you pull the other branch in, don't trust the green checkmark. Roll back, regenerate your migration on the new base, and re-apply. Treat the migration like code that has to be rebased, because it is.
  • Watch the seams between features. A versions table that mirrors your collection, an access rule every query must honor — these are exactly the invariants a textual merge can't see. When you merge in a feature that changes the rules, re-read your new code against the new rules by hand.
  • A clean git merge is the start of the review, not the end.

The feature itself? Categories and tags, working great. The lasting value was the reminder that a migration isn't just a file you write once. It's a claim about the shape of a database that other people, and other branches, are changing underneath you.

Comments

No comments yet. Be the first to comment.

Leave a comment