Auto-migrating on boot is safe at one instance and a race at two. Here is the fix.
There is one line in a lot of SvelteKit starter kits that looks like a kindness:
// src/lib/server/db/index.ts
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
It sits in the connection helper, so migrations apply on every boot. No separate deploy step, no forgotten migration, no "works on my machine" schema drift. For a local dev server, a single-instance app, or a preview deploy, this is genuinely the right default.
Then you scale to two replicas, and it quietly stops being a kindness.
The mechanism
Nothing about migrate() is wrong. The problem is how many callers there are.
Each instance calls the migrator independently, and each one independently decides which migrations are still pending. There is no lock spanning that decision and the write that follows it. So with N replicas booting at once you get N processes asking "is 0003_add_seats applied?" and N processes potentially answering "no" at the same moment.
The concrete version, from a real connection helper:
const MIGRATIONS_DIR = path.resolve(process.cwd(), 'drizzle');
export async function openDb(
url: string,
opts: { schema?: string; max?: number } = {},
): Promise<{ db: Db; client: postgres.Sql }> {
const client = postgres(url, { prepare: false, max: opts.max });
if (opts.schema) {
await client.unsafe(`CREATE SCHEMA IF NOT EXISTS "${opts.schema}"`);
await client.unsafe(`SET search_path TO "${opts.schema}"`);
}
const db = drizzle(client, { schema });
await migrate(db, { migrationsFolder: MIGRATIONS_DIR }); // ← every boot, every replica
return { db, client };
}
Two things in that snippet are load-bearing. prepare: false is required because the Drizzle Postgres migrator uses execution semantics the prepared-statement cache cannot carry. And MIGRATIONS_DIR resolves against process.cwd(), so the migrations have to be deployed alongside the code — which means every replica is carrying the same set of migrations and the same set of pending work.
What the failure actually looks like
The friendly version of this bug is that it usually does fail loudly. The two signatures you will see:
1. column already exists. Two instances both decide 0003 is pending. One applies it. The other gets there a moment later and trips over DDL that is now redundant.
2. A partially applied migration. This is the one worth actually planning for. A migration that contains several statements is not atomic as a unit. If replica A applies ALTER TABLE ... ADD COLUMN seats integer and dies — killed by a rolling deploy, an OOM, a pod eviction — while replica B is mid-flight, the migration table and the schema can disagree. You are now debugging a schema whose recorded state is a fiction.
The timing window is narrow, which is why this survives so long. It usually surfaces on the deploy that first adds a replica, or the one that first scales with autoscaling, and
never again until the next schema change.
The fix is to move the writer out of the request path
Stop letting replicas write. Run migrations as an explicit, single-writer step before the new version serves traffic, and let the boot-time call be a no-op.
// package.json
{
"scripts": {
"db:generate": "drizzle-kit generate", // schema.ts -> SQL in drizzle/
"db:migrate": "tsx scripts/migrate.ts" // the explicit, ordered step
}
}
// scripts/migrate.ts
import 'dotenv/config';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as schema from '../src/lib/server/db/schema';
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set; see .env.example');
const client = postgres(url, { prepare: false });
try {
const db = drizzle(client, { schema });
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations applied to', new URL(url).host);
} finally {
await client.end();
}
Then the deployment guidance is a table, not a paragraph:
| Deployment | What to do |
|---|---|
| Local dev, single instance, preview | Nothing. Boot-time migration is enough. |
| Multiple replicas / autoscaling | Run npm run db:migrate as a pre-deploy step — the only writer — and treat boot-time migration as a no-op. |
Why keep both mechanisms
The instinct after reading this is to delete the boot-time call. Don't. The two mechanisms are doing different jobs:
- The explicit step is the event. It runs once, in order, where you can watch it. A migration becomes a reviewed step in a deploy log rather than something you find out about from an exception three minutes after a rolling deploy started.
- The boot-time call is the guarantee. It means no instance can ever serve a request against an unmigrated schema, even if a deploy skipped the step, a preview pointed at the production database, or someone restored from a backup into a half-migrated state.
Once npm run db:migrate is the only writer, the boot-time migrate() is doing no work — it finds nothing pending and returns. You are not trading correctness for convenience. You are separating the event from the guarantee so that neither has to do both badly.
The one thing this does not solve on its own: migrate() is still not a distributed lock. If someone adds a second pre-deploy step that also migrates, or runs the script from two deployments concurrently, you have the same race with extra steps. Keep it to exactly one
writer.
The checklist
- Is there a command that applies migrations without starting the app? If not, write one.
- Does it run before the new version is serving, as its own step?
- Is exactly one thing running it? Not the app, not a cron, not a second deploy job.
- Does the boot-time migrator stay, as the guarantee rather than the writer?
- Is
MIGRATIONS_DIRactually present in your deployed artifact?process.cwd()-relative paths break silently on some platforms.
On that last point: if your adapter bundles to a directory where drizzle/ was tree-shaken away, the boot-time migrator can throw on a missing folder — and it will look exactly like a database outage, because it happens on the first request that opens a connection.
One more thing worth knowing
If you are deploying to Cloudflare Workers/Pages with D1 rather than Postgres, the whole conversation changes: D1 is an HTTP API, so you are not managing a long-lived pooled connection at all, and the "every replica runs the migrator" problem does not exist in the same form. The SQLite-on-Workers and Postgres-on-a-server approaches trade this failure mode for a different one — local disk semantics that a single-region isolate cannot promise.
Pick based on which failure you would rather debug. The mistake is not choosing — it is shipping the boot-time migrator without having decided who the writer is.
Source of the specifics: this is the pattern from
SvelteKit + Postgres Starter
(v0.1.7), where the concurrency limit is documented in the function's own JSDoc and in
docs/deployment.md §3, so the constraint travels with the code instead of living in a wiki
page nobody reads. 262 tests run against a real Postgres test database, not mocks.
Top comments (3)
Good write-up, and keeping both mechanisms is the right call. One addition that makes the boot-time guarantee safe on its own: wrap it in a Postgres advisory lock (
SELECT pg_advisory_lock(<some constant>)beforemigrate(), unlock after). The second replica then waits for the first to finish and finds nothing pending, instead of racing it. Prisma'smigrate deploydoes this internally, which is part of why the race shows up more with hand-rolled boot migrations.On the partial-migration case: Postgres DDL is transactional, so it's worth checking whether your migrator wraps each run in a transaction. If it does, a replica killed mid-migration rolls back cleanly, and the lock covers the remaining "already exists" race. If it doesn't, one statement per migration file keeps the damage to a single, obvious step.
Shipped as v0.1.8 — you can check it rather than take my word:
github.com/verdantstack/sveltekit-...
MIGRATION_LOCK_KEYis exported from db/index.ts and imported by themigrate script. One key, not two, or they stop excluding each other.
On the transaction half: I checked rather than assumed. Drizzle's
migrate() does wrap the whole run in session.transaction(...), so the
interrupted-replica case already rolled back cleanly before the lock.
The part I got wrong in the post is treating those as separate problems.
They cover different windows. The transaction protects a run that gets
interrupted. It does nothing for two runs that are both in flight — both
read the applied-migrations table before either commits, both decide the
same migration is pending, one dies on
already exists. Only the lockcloses that.
Two implementation traps, both of which cost me a failing test run:
The lock is held on a dedicated connection, not one reserved from the
pool. drizzle's construct() mutates client.options.parsers, and a
reserved connection doesn't expose it. It also deadlocks at max: 1,
which is the test config — you reserve the only connection, then wait on
the pool for a connection that's holding the lock.
And the test that convinced me it works: same key blocks 901ms, different
key grabs it in 177ms. Without the second half the first only proves the
connection was busy.
Thanks for checking Drizzle rather than assuming, and for shipping it.
"The transaction protects a run that gets interrupted, the lock protects two runs in flight" is the clearest way I've seen that put. The 901ms vs 177ms test is the right shape too: without the different-key control, a blocked second call only proves the connection was busy.
The max: 1 deadlock is a good trap to write down. It passes in production and hangs only in the test config, which is the direction you least expect to be surprised in.