# Zero-downtime deploys aren't a platform feature. They're a schema-migration discipline.

Every container platform will tell you it does zero-downtime deployments. App Runner, Fargate, Cloud Run, Kubernetes: they all start new instances, wait for them to be healthy, shift traffic, and stop the old ones. The application is never down. That part is true, and it's also the easy part.

The hard part is the database, because the database doesn't roll. At the moment the new code starts serving requests, the old code is still serving requests too, and both are talking to the same schema. If the new code needs a column the old code doesn't know about, or the old code needs a column the new code just dropped, someone gets an error, and the platform's rolling deploy delivered exactly zero downtime to a system that is nonetheless broken.

So the discipline isn't in the platform. It's in how you change the schema. This is the set of rules we run, the pattern that runs them, and the Lambda that applies them.

## The one rule everything follows from

**Every migration must be compatible with the code that is currently running, and every code change must be compatible with the schema that is currently deployed.** Because a rolling deploy means the previous release and the next release run at the same time, for minutes, against one schema, the schema has to work for both. That's it. Every other rule is that one applied to a specific case.

The consequence people resist: a change that in a development database is one step, "rename column `phone` to `phone_number`", is in production three or four deploys, and the schema is in an intermediate state for days. That's not a sign you're doing it wrong. It's what the rule costs, and it's much cheaper than the alternative.

## Expand, migrate, contract

The pattern is old and it's still the whole answer. Every breaking change is split into an expanding step that only adds, a migration of data or code that uses both shapes, and a contracting step that only removes, with a deploy between each.

<div class="article-figure">
<svg viewBox="0 0 900 300" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Timeline of renaming a column across four deploys with the old and new code overlapping at each rollout. Deploy 1, expand: add phone_number, nullable; old code ignores it. Deploy 2: code writes both columns and reads the new one with a fallback; a backfill job copies phone into phone_number in batches. Deploy 3: code reads and writes only phone_number; the old column is still there, unused. Deploy 4, contract: drop phone; nothing reads it. At every point the running schema works for both the previous and next release.">
<defs><marker id="arrM" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9aa3c7"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Rename phone → phone_number, with old and new code overlapping at every rollout</text>
<line x1="20" y1="60" x2="880" y2="60" stroke="#2a3150"/>
<rect x="20" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="120" y="92" text-anchor="middle" fill="#4fffb0" font-weight="700">1 · expand</text><text x="120" y="110" text-anchor="middle" fill="#f1f3ff">ADD COLUMN phone_number NULL</text><text x="120" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">old code ignores it · safe under N-1</text>
<line x1="222" y1="100" x2="238" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrM)"/>
<rect x="240" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="340" y="92" text-anchor="middle" fill="#7b8cff" font-weight="700">2 · dual write</text><text x="340" y="110" text-anchor="middle" fill="#f1f3ff">write both · read new ?? old</text><text x="340" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">backfill job copies rows in batches</text>
<line x1="442" y1="100" x2="458" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrM)"/>
<rect x="460" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="560" y="92" text-anchor="middle" fill="#7b8cff" font-weight="700">3 · switch</text><text x="560" y="110" text-anchor="middle" fill="#f1f3ff">read and write phone_number only</text><text x="560" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">old column present, unused</text>
<line x1="662" y1="100" x2="678" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrM)"/>
<rect x="680" y="70" width="200" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="780" y="92" text-anchor="middle" fill="#ff6b8a" font-weight="700">4 · contract</text><text x="780" y="110" text-anchor="middle" fill="#f1f3ff">DROP COLUMN phone</text><text x="780" y="124" text-anchor="middle" fill="#9aa3c7" font-size="10">nothing reads it any more</text>
<text x="20" y="166" fill="#9aa3c7">running code during each rollout:</text>
<rect x="20" y="176" width="100" height="18" rx="3" fill="#2a3150"/><rect x="120" y="176" width="100" height="18" rx="3" fill="#4fffb0" opacity="0.6"/><text x="120" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v1 + v2 · both fine with a nullable extra column</text>
<rect x="240" y="176" width="100" height="18" rx="3" fill="#4fffb0" opacity="0.6"/><rect x="340" y="176" width="100" height="18" rx="3" fill="#7b8cff" opacity="0.6"/><text x="340" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v2 + v3 · both write, both read with fallback</text>
<rect x="460" y="176" width="100" height="18" rx="3" fill="#7b8cff" opacity="0.6"/><rect x="560" y="176" width="100" height="18" rx="3" fill="#7b8cff"/><text x="560" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v3 + v4 · old column unused by either</text>
<rect x="680" y="176" width="100" height="18" rx="3" fill="#7b8cff"/><rect x="780" y="176" width="100" height="18" rx="3" fill="#ff6b8a" opacity="0.6"/><text x="780" y="209" text-anchor="middle" fill="#9aa3c7" font-size="10">v4 + v5 · drop is invisible to both</text>
<text x="450" y="250" text-anchor="middle" fill="#ffd166">Four deploys instead of one. Each one can be rolled back by moving the image tag, because the schema works for the release before it.</text>
<text x="450" y="272" text-anchor="middle" fill="#9aa3c7">The intermediate schema lives for days. That's the price, and it's the only price.</text>
</g>
</svg>
</div>

Some cases, spelled out, because the pattern is easy to nod at and easy to get wrong in detail:

- **Adding a column:** nullable or with a default, in one step. Never `NOT NULL` without a default, because the old code doesn't set it.
- **Adding an index:** `CREATE INDEX CONCURRENTLY` in Postgres, outside a transaction. A plain `CREATE INDEX` locks the table for writes for as long as it takes, which on a large table is the outage the rolling deploy was supposed to prevent.
- **Renaming anything:** the four steps above. There is no shortcut.
- **Changing a type:** add a new column of the new type, dual-write, backfill, switch, drop. Same four steps.
- **Dropping a column:** only after a release has shipped that doesn't reference it, and after you've confirmed in production that nothing does. ORMs that `SELECT *` will surprise you here.
- **Adding a constraint:** add it `NOT VALID` first, which enforces it for new rows only, then `VALIDATE CONSTRAINT` in a later step once the backfill has made the existing rows compliant.

## Migrations are not backfills

A migration changes structure. It runs in seconds. A backfill changes data. It can run for hours. Putting the second inside the first is how you get a deploy that holds a lock on the orders table for forty minutes while every request waits.

Our migrations are DDL only, plus at most a data change that's guaranteed to touch a bounded number of rows (a lookup table, a config row). Anything that touches "all rows of a large table" is a backfill, and a backfill is a job: a Lambda or a worker that processes a batch, records its checkpoint, and is invoked again, with a concurrency of one. It is idempotent, it can be stopped and resumed, and it runs *between* deploy 2 and deploy 3 for as long as it needs to, while the dual-write code keeps new rows correct.

```sql
-- migration 0042, runs in seconds
ALTER TABLE customers ADD COLUMN phone_number text;

-- backfill, runs as a job in batches of 5,000 until it reports zero rows
UPDATE customers
   SET phone_number = phone
 WHERE id IN (SELECT id FROM customers WHERE phone_number IS NULL AND phone IS NOT NULL ORDER BY id LIMIT 5000);
```

## Where the migration runs: a Lambda in the deploy

The migration has to run against the production database, from something that can reach it (it's in a private subnet), with credentials that can alter the schema (the application's own role can't, on purpose), before the new code starts but after the new image exists. That's a very specific set of requirements and it maps onto exactly one thing in our stack: a Lambda inside the VPC, with its own database role, invoked by the deploy script between "images built" and "service rollout".

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Deploy sequence with the migration step. Build images, then invoke the schema-migrate Lambda synchronously: it takes an advisory lock, applies pending migrations in order, records them in a migrations table, releases the lock. Only if that returns success does cdk deploy roll the App Runner services to the new image. Then post-deploy verification. If the Lambda fails, the deploy stops before any new code serves traffic.">
<defs><marker id="arrD" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="50" width="160" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="95" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">build images</text><text x="95" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">new code exists, not running</text>
<line x1="177" y1="85" x2="213" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="215" y="40" width="240" height="90" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="335" y="62" text-anchor="middle" fill="#ffd166" font-weight="700">schema-migrate Lambda · in the VPC</text><text x="335" y="82" text-anchor="middle" fill="#f1f3ff" font-size="11">pg_advisory_lock · apply pending, in order</text><text x="335" y="100" text-anchor="middle" fill="#f1f3ff" font-size="11">record in schema_migrations · unlock</text><text x="335" y="118" text-anchor="middle" fill="#9aa3c7" font-size="11">own DB role with DDL · 5-minute timeout</text>
<line x1="457" y1="85" x2="493" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/><text x="475" y="72" text-anchor="middle" fill="#4fffb0" font-size="10">ok</text>
<rect x="495" y="50" width="180" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="585" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">rollout</text><text x="585" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">old and new code overlap, schema fits both</text>
<line x1="677" y1="85" x2="713" y2="85" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="715" y="50" width="170" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="800" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">verify</text><text x="800" y="100" text-anchor="middle" fill="#9aa3c7" font-size="11">health · release SHA</text>
<path d="M335,132 L335,160 L95,160 L95,122" fill="none" stroke="#ff6b8a" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrD)"/><text x="215" y="176" text-anchor="middle" fill="#ff6b8a" font-size="11">on failure: stop here · no new code has served a request · fix forward</text>
</g>
</svg>
</div>

The Lambda is about eighty lines. It opens a connection with the migration role, takes `pg_advisory_lock(42)` so two deploys can't race, reads the `schema_migrations` table, applies each pending file in order inside its own transaction (except the `CONCURRENTLY` ones, which can't be in a transaction and are marked as such), records each one, and releases the lock. It's invoked synchronously by the deploy script, which does not proceed to the rollout unless the invocation returns success. A five-minute timeout is the enforcement of "migrations are not backfills": if it takes longer than that, it was a backfill, and the deploy fails before it hurts anyone.

The same Lambda, with the same code, runs against [each pull request's database](/en/blog/preview-environments-per-pull-request-on-aws) on stack creation and against staging on every merge to `main`. By the time a migration reaches production it has run a dozen times.

## Rollback, and why we don't write "down" migrations

A code rollback is a tag change: point the service at the previous image, four minutes, done. A schema rollback is not a tag change, and pretending it is by writing a `down()` for every migration produces a false sense of safety, because `DROP COLUMN` in a down migration destroys the data that arrived since the up migration ran.

So we don't write them. The safety comes from the discipline instead: because every migration is compatible with the previous release, rolling the *code* back is always safe, and rolling the *schema* back is never necessary. If a migration itself is wrong, the fix is a new migration that goes forward. In eighteen months we have wanted a down migration exactly zero times, and we've rolled code back six times, each one uneventful.

## The checklist we put in the pull request template

- Does this migration work if the currently deployed code keeps running against it? If no, split it.
- Does the code in this PR work against the currently deployed schema? If no, the migration ships first, in its own PR.
- Any `NOT NULL` without a default, any rename, any type change, any drop? Then it's an expand/contract sequence: which step is this?
- Any index on a table over a million rows? `CONCURRENTLY`, outside a transaction.
- Any statement that touches more rows than you can count? That's a backfill; move it to a job.
- Will this run in under a minute in production? If unsure, time it against a staging snapshot.

Six questions. They add about ten minutes to a schema PR and they've removed the category of incident where the platform did its job perfectly and the users saw errors anyway.

If your deploys are zero-downtime until they touch the database, [we'll help you draw up the discipline](/contact). It's mostly the checklist above and one Lambda.
