The expand/contract migration pattern (also called parallel change) lets you evolve a database schema without ever breaking the application that's reading and writing to it. Instead of changing a column or table in one risky step, you split the change into safe, independently deployable phases — and that separation is what makes rolling deploys, blue/green releases, and multi-service architectures survive schema changes without downtime.
The problem with one-shot schema changes
A single ALTER TABLE statement that renames a column, tightens a constraint, or changes a type looks simple in isolation. The trouble starts when application code and database schema can't change atomically. During a rolling deploy, old application instances and new ones run against the same database simultaneously. If the new schema no longer matches what the old code expects, every request handled by an old instance starts failing until the deploy finishes. The same problem shows up with read replicas lagging behind a primary, background workers on an older code version, or third-party services with cached query definitions.
Expand, migrate, contract: the three phases
The pattern breaks a breaking change into three non-breaking steps:
- Expand — add the new structure alongside the old one. Add a new column, a new table, or a new constraint, but don't remove anything. Both old and new application code can run against this schema.
- Migrate — backfill and dual-write. Copy existing data into the new structure, and update application code to write to both old and new locations until every consumer has moved over.
- Contract — once every service, replica, and worker is confirmed running the new code, remove the old structure. This is the only phase that's actually destructive, and by the time you run it nothing depends on the old shape anymore.
Each phase is its own deploy, and each one is independently reversible until the contract step.
A worked example: renaming a column
Renaming customers.email to customers.contact_email is a textbook case where a naive ALTER TABLE ... RENAME COLUMN breaks any code still using the old name the moment it ships.
- Expand: add
contact_emailas a nullable column alongsideemail. - Migrate: backfill
contact_emailfrom existingemailvalues, then deploy application code that writes to both columns and reads from whichever is populated. Once all instances are on this version, switch reads tocontact_emailonly. - Contract: after confirming no code path still references
email, drop the column.
The same three-phase shape applies to changing a column's type, splitting a table, tightening a NOT NULL constraint, or replacing a foreign key relationship — anything where the "before" and "after" shapes aren't compatible with the same application code.
Where teams get it wrong
The most common failure is skipping straight from expand to contract because the migrate phase feels like unnecessary overhead. Without a genuine dual-write or backfill window, you're really just doing a one-shot change with extra steps. A close second is contracting too early — dropping the old column before confirming every consumer, including batch jobs, cron scripts, and analytics pipelines, has actually moved over. Track which services still touch the old structure before you contract, not after something breaks in production.
Generating the SQL for each phase
Each phase of expand/contract is its own schema state, which means each phase transition is its own schema diff. That's a natural fit for a schema comparison workflow: keep a target schema definition for the phase you're moving to, compare it against what's currently deployed, and generate the migration SQL for just that step rather than hand-writing DDL for expand, migrate, and contract separately. FoxSchema compares two schemas — across PostgreSQL, MySQL, MariaDB, SQL Server, Azure SQL, Oracle, IBM Db2, SQLite, ClickHouse, or Amazon Redshift — and produces the exact SQL to move from one to the other, with a dry run and a pre-migration snapshot before anything touches the target. Running each expand/contract phase through a diff also gives you a paper trail: the generated SQL for every step is the actual record of what changed and why, which pairs well with the broader set of tactics in our zero-downtime migrations guide.
Get started
You can review the full command reference in the docs, or run FoxSchema against your own databases today by self-hosting the Docker container.