QUESTPIE
ShipMigrations

Zero downtime

A rolling deploy runs old and new code against one database at the same time. Sequencing a required column, a drop, a rename and a backfill so no request fails while that is true.

View markdown

How do you add a required column without a maintenance window? Not in one deploy. Replicas swap over one at a time. The old ones keep serving until the last one is gone.

So every migration has to be safe for the code that is already running.

Which changes are already safe

ChangeSafe mid-rolloutWhy
Add a nullable columnYesOld code never selects it
Add a column with a defaultYesOld inserts get the default
Add a tableYesNothing reads it yet
Add an indexYesQueries do not change
Add a NOT NULL columnNoOld inserts omit it and fail
Drop a columnNoOld code still selects it
Rename a columnNoIt is a drop and an add

In QUESTPIE terms, f.text(120) gives you a nullable column. .required() adds NOT NULL. .default(value) adds a column default. So the safe row and the unsafe row are one method apart. That is why this one catches people.

A required column is three deploys

Deploy 1: nullable
import { collection } from "#questpie/factories";

export const posts = collection("posts")
	.fields(({ f }) => ({
		title: f.text(255).required(),
		slug: f.text(120), // new, and nullable on purpose
	}))
	.title(({ f }) => f.title);

Ship that. Old replicas ignore the column.

Deploy 2 does no schema work. Ship code that writes slug on every create, and run a job that fills the existing rows. Wait until it finishes.

Deploy 3: required
slug: f.text(120).required(),

Each step is its own questpie migrate:create and its own deploy. Three migrations, no failed request.

Removing a column is the same in reverse

Stop reading it first. Deploy that. Drop it in a later deploy. Drop it while old replicas are still selecting it and their queries fail.

Renaming is never a rename

migrate:create compares two snapshots and nothing else. It has no way to know that a dropped column and a new column are the same thing.

Do it by hand instead. Add the new column. Write both. Backfill. Switch reads. Drop the old one in a later deploy. That is four deploys for a rename. Good reason to name the field right the first time.

Backfills are jobs, not migrations

Every migration runs inside one transaction. A backfill over millions of rows holds that transaction open for minutes and blocks everything queued behind it.

Split them. The schema change is a migration. The backfill is a job that works in bounded batches and can be stopped and resumed.

A migration is not a place for side effects

The transaction only rolls back database work. An HTTP call, an email or a dispatch to an external queue has already happened by the time the migration fails. Put those in a job.

Down is not a recovery plan

questpie migrate:down reverses the DDL. It cannot restore rows that an up() deleted. Roll back a migration that dropped a column and you get the column back, empty.

Take a backup immediately before anything destructive. Treat restore, not rollback, as the recovery path.

Check it before you deploy

Two checks, both cheap. Run the generator once more on a clean tree:

questpie migrate:create
Found 0 operations
⏭️  No schema changes detected, skipping migration generation (No operations)
⏭️  No schema changes detected, skipping migration generation

The generator says it, then the command says it again. That is the result you want. The committed migrations and the schema agree. A second migration appearing here means they do not. The deploy will not do what you think.

Then look at what the deploy is about to apply:

questpie migrate:status

Your new migration should be the only thing under Pending. Anything else there is a migration some earlier deploy never applied. Find out why before you add to the pile.

On this page