Upgrading pg-boss 10 → 12 When There Is No Migration Path
pg-boss 12 cannot migrate a v10 schema - v11 moved job storage to partitioned tables and the migration chain starts after that cliff. Here's how I upgraded a production job queue anyway - drop the schema, rebuild queue state from my own tables, and the job-ownership guard the rollout taught me to add.

I run a SaaS SEO crawler as a side product: a Nuxt web app, a Node worker, and Postgres in the middle. The job queue between web and worker is pg-boss — jobs live in a pgboss schema right next to the application data, which is exactly the property I picked it for. No Redis, no RabbitMQ, one database to back up and reason about.
This week I took that queue from pg-boss 10 to 12.26. The upgrade itself is a version bump and a handful of API changes. The interesting part is what's missing: there is no migration path from a v10 schema to v12. This post is the write-up of how I handled that in production, what broke anyway, and the hardening the rollout forced on me.
Why Upgrade at All
The queue on v10 worked. But I was doing a dependency sweep across the whole monorepo, and a job queue is the last place I want to be two majors behind: it owns DDL in my database, it runs migrations on its own tables, and the further you fall behind, the worse the eventual jump gets. The motivation was boring. The execution was less boring.
The Cliff: v11 Repartitioned the World
pg-boss tracks its own schema version in pgboss.version. v10 sits at schema version 24. In v11, job storage moved to partitioned tables — a structural rewrite of how jobs are laid out, not an incremental ALTER TABLE. And v12's built-in migrations only reach back to schema version 26, the v11 layout.
That means: point pg-boss 12 at a database that pg-boss 10 built, and it cannot upgrade it. There's no supported path from schema 24 to the partitioned layout. At this point you have three options:
- Upgrade in two hops — v10 → v11 → v12, letting each major handle its own transition.
- Migrate the data yourself — SQL that moves rows from the old tables into the new partitioned ones.
- Drop the schema and start fresh — let v12 create its own world, rebuild queue state from your side.
I picked the third, and I want to be precise about why, because it's only right under a specific condition.
The Condition That Makes "Drop It" Safe
Everything hinges on one architectural decision I'd made long before this upgrade: the queue is not my source of truth. It's a delivery mechanism.
In my crawler, every crawl run is a row in my own crawl_runs table with a status (queued, running, done, failed). The pg-boss job just says "go process run X". Queues and cron schedules aren't precious either — the worker re-creates every queue and re-registers every schedule at boot, idempotently. And the worker already had a boot-recovery step: any run stuck in running (because a deploy killed the worker mid-crawl) gets reset and re-sent to the queue.
Under those conditions, the entire pgboss schema is recoverable state. Dropping it loses in-flight job rows, but every job I care about can be regenerated from crawl_runs. The other two options would have been more work to preserve data I could rebuild in one query.
If your jobs carry payloads that exist nowhere else — emails queued with their full body, webhooks with one-shot tokens — this approach is off the table; do the two-hop dance instead. That's the honest boundary of this post's advice.
The Implementation: Detect, Drop, Recreate, Requeue
The worker boots first in my deploy order, so it got the job of dealing with legacy schemas. Before starting pg-boss, it checks the schema version and drops anything pre-v11 (sanitized excerpt, Drizzle on Postgres):
// pg-boss v12 migrates schema versions >= 26 (v11) automatically but has no
// migration path from v10 (schema 24). Our queue state is recoverable — the
// worker re-enqueues interrupted/queued crawl runs at boot and re-creates
// queues + cron schedules — so a legacy schema is simply dropped.
const MIN_MIGRATABLE_SCHEMA = 26
export async function dropLegacyQueueSchema(db: Db): Promise<boolean> {
let version: number
try {
const res = await db.execute(sql`select version from pgboss.version`)
version = Number(res.rows[0]?.version)
} catch {
return false // no pgboss schema yet — fresh install
}
if (!Number.isFinite(version) || version >= MIN_MIGRATABLE_SCHEMA) return false
console.warn(`[queue] legacy pg-boss schema v${version} — dropping for fresh v12 install`)
await db.execute(sql`drop schema pgboss cascade`)
return true
}
The function returns whether a reset happened, and the boot sequence uses that signal. Normally, boot recovery only re-queues runs stuck in running. But after a schema reset, runs sitting in queued state also lost their job rows — they were queued in the old schema that no longer exists. So the recovery widens:
// After a legacy-schema reset the old queue's jobs are gone, so 'queued'
// runs must be re-sent too — safe only then, since the fresh schema
// can't hold duplicate jobs yet.
const resumeStatuses = queueWasReset ? ['running', 'queued'] : ['running']
const requeued = await db.update(crawlRuns)
.set({ status: 'queued', progressPct: 0, startedAt: null, finishedAt: null })
.where(inArray(crawlRuns.status, resumeStatuses))
.returning({ id: crawlRuns.id })
for (const r of requeued) await boss.send('crawl.run', { runId: r.id }, JOB_OPTIONS)
The comment in that snippet carries the subtle bit: re-sending queued runs is only safe immediately after a reset. On a normal boot, a queued run already has a live job in the queue, and re-sending would double-crawl it. A brand-new schema can't hold a duplicate yet, so the reset boot is the one moment this is race-free.
One deploy-ordering wrinkle I accepted consciously: web pods that boot before the worker has dropped the old schema will fail their queue sends — pg-boss 12 refuses to start against schema 24. My web app's queue helper only caches the pg-boss instance after a successful start, so those pods retry on the next request and heal themselves once the worker is up. A few failed "start crawl" clicks during one deploy window was a trade I was fine with.
The v12 API Changes I Actually Hit
For a two-major jump, the code-level churn was small:
- Named export.
import PgBoss from 'pg-boss'becameimport { PgBoss } from 'pg-boss'. Trivial, but it's in every file that touches the queue. stop()lost itswaitoption. My graceful shutdown wasboss.stop({ graceful: true, wait: true, timeout: 25_000 }); thewaitflag is gone in v12 and the call is juststop({ graceful: true, timeout: 25_000 }).- No more archive table. Completed jobs used to move to a separate archive; in v12 they stay in the job table and are cleaned up by
deleteAfterSeconds(default seven days). If you had monitoring or manual queries pointed at the archive, they're pointed at nothing now.
I verified the whole dance against a copy of the dev database before shipping: old schema at version 24 dropped, v12 created its schema at version 37, and a heartbeat job made the round trip. That's the test I'd insist on — not unit tests, but the actual boot sequence against an actual v10 schema.
What Production Taught Me Within the Hour
The rollout surfaced a bug that had technically existed all along; the upgrade just made it fire.
Here's the sequence. A deploy restarts the worker while a crawl is mid-flight. The new worker boots, finds the run stuck in running, resets it and re-sends it — a new pg-boss job now owns that run. So far, by design. But the old worker's handler doesn't always die instantly: its catch block can still execute during shutdown, and it did what catch blocks do — marked the run failed and filed a failure alert, right on top of the takeover handler's fresh work. I watched a run that was being happily re-crawled get stamped failed by its own ghost during the rollout.
The fix is ownership. The crawl_runs row now records which pg-boss job currently owns it, and every terminal write — marking the run done or failed — must present the matching job id:
// Claim ownership when the handler starts working the run:
await db.update(crawlRuns)
.set({ status: 'running', startedAt: new Date(), jobId })
.where(eq(crawlRuns.id, runId))
// Terminal write, guarded — a superseded handler matches zero rows:
const marked = await db.update(crawlRuns)
.set({ status: 'failed', errorSummary: message, finishedAt: new Date() })
.where(and(eq(crawlRuns.id, runId), eq(crawlRuns.jobId, job.id)))
.returning({ id: crawlRuns.id })
if (marked.length) {
await recordCrawlFailed(db, runId, message)
} else {
console.warn(`[crawl.run] ${runId} taken over by another job — stepping aside`)
}
The boot requeue clears jobId when it resets a run, so ownership passes cleanly to whichever handler picks it up next. The dying handler's write matches zero rows, logs one line, and steps aside instead of filing a false alert.
This is a fencing token — the same pattern you'd use with any at-least-once delivery system. My opinion: if deploys can kill workers mid-job and recovery re-enqueues work, you need this guard regardless of queue library. The upgrade didn't create the race; it just deployed often enough in one evening to lose the coin flip.
The Checklist I'd Hand You
If you're staring at the same 10 → 12 jump — the first item decides everything else:
- Can you rebuild queue state from your own tables? If yes, the drop-and-recreate path is dramatically simpler than stepping through v11. If no, do the two-hop upgrade and budget real time for it.
- Gate the drop on the schema version. Read
pgboss.versionand only drop below 26. The same code path is then a no-op forever after, and safe on fresh installs. - Widen boot recovery exactly once. Re-sending
queuedwork is only duplicate-safe against a freshly created schema. Wire that to the reset signal, not to a config flag someone forgets to turn off. - Grep for the API changes. Named export,
stop()options, and anything referencing the archive table. - Rehearse against a real v10 schema. Restore a dump, boot the worker, watch the version go from 24 to 37, run one job end to end.
- Add a job-ownership guard before you deploy often. Terminal status writes should carry the owning job id. You'll want it the first time a deploy interrupts a job and recovery re-enqueues it.
My Take
"No migration path" sounds like a blocker, but it's really a question the library is asking you: is your queue state precious? If the answer is yes, you've coupled your source of truth to a dependency's internal schema, and this upgrade is the bill for that. If the answer is no — because your domain tables can regenerate every job — then a missing migration path costs you one DROP SCHEMA and a widened requeue.
I'd rather own that answer explicitly than discover it during an incident. The queue moves work; my tables own truth. pg-boss 12 has been unremarkable since — which is the highest compliment I can pay a job queue.
If you're planning a similar upgrade — a queue migration, a Node worker that needs to survive deploys, or a Postgres-backed system that's a few majors behind — I've done this on production systems and I'm happy to take a look at yours.
Useful References
- pg-boss on GitHub — releases and migration notes
- pg-boss documentation —
deleteAfterSeconds, queue policies,stop()semantics - Postgres partitioning docs — the storage model v11 moved to
Enjoyed this?
Get new posts as they land.
Keep reading

Shopware Decoration Best Practices: Override One Method, Keep Others Untouched
Learn the golden rule of Shopware decorations - how to efficiently override only what you need while letting PHP inheritance handle the rest automatically.

Code Quality Tools That Actually Matter - Your PR Review Checklist
Stop shipping bugs and messy code. These essential code quality tools catch issues before they hit production, making your team faster and your code cleaner.

Summary of Clean Code by Robert C. Martin - Essential Guidelines for Better Software
Code is clean if it can be understood easily by everyone on the team. A comprehensive summary of Robert C. Martin's Clean Code principles and best practices.