Skip to content
AhmadKhidir

POST

Expand and contract: the migration that does not break things

Jul 20266 MIN READ

#databases#migrations#postgres#architecture

Every team has a database migration horror story, and the shape is always the same. Someone runs a migration that renames a column, or changes a constraint, or drops a table, and it works fine against the local database with nobody else in it. Then it runs in production, where there is a queue of requests in flight, and the old code and the new schema meet at the worst possible moment, and suddenly every request is throwing errors about a column that no longer exists.

The version of this that I want to talk about is the avoidable one. A huge fraction of migration pain is not caused by the database. It is caused by coupling things that did not need to be coupled. The schema change, the code change, and the deploy were treated as one atomic event, when they could have been three separate events, sequenced so that at every moment the system works. That sequence has a name, expand and contract, and it is one of the most valuable boring techniques in backend engineering.

The core idea

Expand and contract is a pattern for changing a database schema without downtime, and the principle is almost embarrassingly simple. Change the schema in stages, and make sure every intermediate state is one the current code can tolerate. Then change the code. Then remove the old schema, and make sure every intermediate state there is also one the current code can tolerate.

The name comes from the two phases. The expand phase adds the new structure while the old structure still exists, so old code and new code can both work. The contract phase removes the old structure once nothing uses it anymore. The whole pattern is a series of safe, reversible steps that add up to a change that would have been terrifying as a single step.

A rename, done properly

The canonical example is renaming a column, and it is the one that converts people to the pattern, because everyone has been burned by it. The naive version is a single migration: rename the column, deploy the code that uses the new name. The window of danger is the deploy, during which old code, still running on the old release, reads and writes the old column, and the new schema, which has renamed it, throws errors.

The expand and contract version is three steps. First, add the new column and keep writing to both columns, a dual write. Deploy this, and the system works: both columns exist, both are written, reads still use the old column. Second, deploy the code that reads from the new column. Now the system works again: both columns are written, the new column is read. Third, migrate any historical rows that were written before the dual write, then drop the old column. Deploy nothing, because no code change is needed. The system works at every step, and at no point did old code touch a missing column.

The pattern generalizes far beyond renames. Adding a new required column to a table with existing rows? Add it nullable first, backfill it, then add the constraint. Changing the meaning of a field? Introduce the new interpretation as a new field, migrate, then remove the old one. The steps are always the same shape: add the new, keep the old alive, flip the readers, remove the old.

The operational details that matter

The pattern is conceptually simple, and the details are where teams slip. The big one is that the dual write has to be genuinely dual. If you add the new column and write to it only in the happy path, the unhappy path keeps writing to the old column, and your backfill is incomplete, and the flip to reads on the new column produces the "where did my data go" incident. The dual write has to be tested with real traffic patterns, not just unit tests.

The second detail is the backfill. When you add a column to a large table, the ALTER TABLE that adds a default can lock the table and block writes. The discipline is to add the column as nullable, no default, backfill in batches, then add the constraint. On a table with millions of rows, the backfill is a job, not a statement, and it should be a job you can run in chunks and resume.

The third detail is the observability of the transition. Every step of the pattern should be a deploy you can watch. The dual write phase is where you learn whether your new code is producing the same data as the old code. Compare counts, compare distributions, compare the things that matter. The flip is only safe when the dual write has proven the new path is sound.

Why teams skip it

The honest answer is that expand and contract feels like more work than the one step migration, because it is. Three deploys instead of one, a backfill job instead of a statement, dual writes that live in the codebase for a release cycle. When the table is small, the service is internal, and the traffic is low, the one step migration is fine, and the team that always does the one step is being pragmatic.

The problem is that the habit does not scale. The team that has never done a staged migration will not reach for it when the table is large and the service is customer facing, because they do not have the muscle memory. The first time you need it is not the time to learn it.

The other reason teams skip it is a misunderstanding of what "downtime" means in a modern deploy. If you deploy new code and old code at the same time, which is what rolling deploys do, the one step migration breaks the old instances. The pattern exists to keep old and new instances alive simultaneously, and if you have ever deployed to a platform that runs both versions during a rollout, you have already committed to the idea that intermediate states matter. Expand and contract is just taking that idea seriously for the schema.

The migration that runs itself

There is a version of this that removes the "remember to run the second migration" problem entirely. Keep the migrations in code, and make the contract phase a separate migration that runs only after the deploy has completed and been observed. The team that ships this way has a rule: a migration is only safe to run when nothing in the previous release references what it removes. The rule makes the ordering explicit instead of tribal.

The tools have caught up with the pattern. Modern migration tooling supports reversible steps, check constraints added in a separate step, and background backfills. But the tooling is the easy part. The pattern is a habit, and the habit is the thing that prevents the incident.

The boring superpower

Expand and contract is not clever. It is not the kind of technique you present at a conference. It is a series of small, boring, reversible steps that, taken together, let you do something that used to require a maintenance window and a prayer: change the shape of a live database while users keep using it.

The teams that do it well treat database migrations as a sequence of deploys with a lifecycle, not as a script to run and forget. They name the intermediate states, they test them, they observe the dual write, and they remove the old structure only when the evidence says it is safe. The reward is invisible, which is the point. The incident that never happened because the schema change was staged is the best incident you will ever have.