# Zero downtime (/docs/ship/migrations/zero-downtime)

---
title: Zero downtime
description: 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.
kind: guide
package: questpie
---

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

| Change                      | Safe mid-rollout | Why                            |
| --------------------------- | ---------------- | ------------------------------ |
| Add a nullable column       | Yes              | Old code never selects it      |
| Add a column with a default | Yes              | Old inserts get the default    |
| Add a table                 | Yes              | Nothing reads it yet           |
| Add an index                | Yes              | Queries do not change          |
| Add a `NOT NULL` column     | No               | Old inserts omit it and fail   |
| Drop a column               | No               | Old code still selects it      |
| Rename a column             | No               | It 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

```ts title="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.

```ts title="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](/docs/code/jobs) that works in bounded batches and can be stopped and
resumed.

<Callout type="warn" title="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.
</Callout>

## 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:

```bash
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:

```bash
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.
