# Make Database Migrations Survive Mixed Application Versions

> Use an expand-and-contract compatibility timeline to change production schemas safely while old and new application instances overlap.

Canonical URL: https://www.devobs.io/articles/expand-contract-database-migrations/
By: Nina Patel
Published: 2024-06-18T14:38:52.854Z
Updated: 2026-09-06T08:31:04.426Z
Section: Architecture

A safe production schema change is a compatibility program, not a single migration. Assume old code, new code, backfill workers, and replicas can overlap for longer than the deployment estimate. Expand the schema so both application versions work, move data and traffic while measuring invariants, then contract only after the [rollback window](https://www.devobs.io/articles/rollback-compatible-release-design/) closes.

## Write the compatibility timeline first

Describe each release in terms of what it reads and writes. Suppose `customers.name` must become `given_name` and `family_name`. Release A adds nullable columns but keeps reading and writing `name`. Release B writes all three fields and can read either representation. A bounded backfill populates the new columns. Release C reads only the new fields but still dual-writes for rollback. Release D stops writing `name`. Only a later migration drops it.

This sequence makes rollback explicit. Until Release C, rolling back means returning to code that still understands `name`. After writes to `name` stop, rollback requires either resuming dual writes or a reverse backfill. Record that boundary in the release plan instead of discovering it during an incident.

[PostgreSQL table modification documentation](https://www.postgresql.org/docs/current/ddl-alter.html) explains that a new column without a default is initially filled with `null`, that `SET NOT NULL` checks existing rows immediately, and that a type change succeeds only if existing values can be converted, sometimes with a `USING` clause. Those details support additive steps, explicit backfills, and delayed enforcement instead of relying on a single in-place schema change to carry mixed application versions.

## Backfill without competing with production

A backfill is production workload. Process stable key ranges in small transactions, cap concurrency, and persist a checkpoint. Make the update idempotent: update only rows whose new representation is absent, and derive it deterministically from the old value. Measure remaining rows, write latency, replica lag, deadlocks, and error rate. Pause automatically when the service envelope is threatened.

Dual writes need an owner and an end date. Put them in one application transaction when both values share a database, and alert when the representations disagree. If multiple writers exist, upgrade every writer or enforce the temporary invariant in a shared boundary. A trigger can bridge unknown writers, but it adds hidden behavior and needs the same removal plan as application dual writes.

For a new index on a busy table, choose the database-specific online mechanism. [PostgreSQL CREATE INDEX documentation](https://www.postgresql.org/docs/current/sql-createindex.html) states that `CREATE INDEX CONCURRENTLY` permits inserts, updates, and deletes during the build, but does more work, waits on transactions, cannot run in a transaction block, and can leave an invalid index after failure. Your migration runner must detect that state rather than treating a failed command as a clean rollback.

## Add enforcement after the data is ready

Do not add `NOT NULL` before old instances can populate the column. First deploy compatible writers, backfill, and query for violations. Then introduce or validate the constraint using the least disruptive mechanism supported by your database. Keep the application check until the [database constraint](https://www.devobs.io/articles/database-constraints-versus-application-validation/) is active everywhere it matters.

The dangerous failure case is an apparently harmless cleanup deployed while one old worker still runs. That worker may insert nulls, reference a removed column, or overwrite a new representation. Gate contraction on evidence: deployment inventory shows no incompatible version, queues contain no old jobs, scheduled tasks use updated code, and rollback has moved past the old binary.

## Use a release checklist

For every schema change, write the old-reader/new-writer and new-reader/old-writer compatibility tests. Rehearse the migration on a production-sized copy, including cancellation and retry. Set statement and lock timeouts, observe the backfill, verify invariants with independent queries, and preserve a reversible path until the agreed cutoff.

The next step is to take one pending destructive migration and split it into four tickets: additive schema, compatible application, measured backfill, and delayed cleanup.

Reviewed on 2026-09-06.

## Source references

- <https://www.postgresql.org/docs/current/ddl-alter.html>
- <https://www.postgresql.org/docs/current/sql-createindex.html>
