Zero-Downtime Migrations: The Expand and Contract Pattern with Flyway or Liquibase
Picture a normal Tuesday deploy. Your new service version starts, your old one is still serving traffic, and a migration just renamed a column. Within seconds, the old version begins throwing errors. The fix is not a faster deploy, but a safer way to change the schema.
Why Schema Changes Break Rolling Deploys
During a rolling deploy, two application versions share one database. Therefore, every schema change must work for both of them at the same time. A plain rename or drop fails this test immediately, because the old code still expects the old shape.
Moreover, there is a second risk. Some statements hold a lock that blocks every query on the table. As a result, even a correct change can freeze your service for seconds. We will measure that risk later in this article.
The Pattern in Three Phases
The expand and contract pattern solves both problems by splitting one risky change into safe steps. It is also known as parallel change, a write-up by Danilo Sato on Martin Fowler’s site from 2014. It has three phases: expand, migrate and contract. First you support old and new together, then you move consumers over, and finally you remove the old version.
Fowler’s page also gives a useful warning. If you never run the contract phase, you can end up worse off than you started. So treat the last step as part of the plan, not as optional cleanup.
| Phase | Database step | Application step |
|---|---|---|
| Expand | Add the new column, nullable | Deploy code that writes to both columns |
| Migrate | Backfill old rows in small batches | Switch reads to the new column |
| Contract | Add constraints, then drop the old column | Remove the old code path |
A worked example: renaming a column
Suppose you want to rename customer to client. A direct rename is fast, in fact it took about half a millisecond in our test. However, speed is not the issue. The old application version would fail the moment the rename lands.
Instead, add a nullable client column first. Next, release code that writes both columns and backfill old rows in small batches. After that, switch reads to client. Finally, in a later release, drop customer. Because each step is compatible with the previous app version, you can pause or roll back at any point.
Using Flyway or Liquibase for Each Phase
Both tools fit this rhythm well, since each phase becomes its own migration. Flyway records a checksum for every versioned migration, and its documentation advises rolling forward with a new script instead of editing an applied one. Liquibase identifies a changeset by id, author and file path, and it also stores a checksum.
| Topic | Flyway | Liquibase |
|---|---|---|
| Unit of change | Versioned SQL file, such as V3__add_client.sql | Changeset in a changelog |
| Fixing a mistake | Add a new versioned migration | Add a new changeset, or use the rollback attribute |
| Transactions | One migration per file | One transaction per changeset by default |
| Good fit for | Plain SQL teams | Teams wanting preconditions and multiple formats |
One practical rule applies to both: keep the contract phase in a later release than the expand phase. Otherwise, the old application version can still be running when the old column disappears.
The Lock Trap, With Real Numbers
Now for the part many guides skip. To see the real cost of common statements, I ran them on PostgreSQL 16.15 against a table with three million rows. The chart below shows how long each statement held its lock. The scale is logarithmic, because the differences are enormous.

Two results stand out. First, changing int to bigint rewrote the table and took about 3.8 seconds. Second, a plain SET NOT NULL scanned every row under a blocking lock for about 340 milliseconds. Meanwhile, adding a nullable column, or one with a constant default, finished in about a millisecond.
A safer way to add NOT NULL
The PostgreSQL documentation explains the trick. If a valid CHECK constraint already proves no NULL exists, the scan is skipped. So add the constraint as NOT VALID, validate it, and only then set NOT NULL. Here is the full sequence, which I verified end to end on PostgreSQL 16 using a throwaway table:
CREATE TABLE orders (id int PRIMARY KEY, note text); INSERT INTO orders SELECT g, 'x' FROM generate_series(1, 100000) g; ALTER TABLE orders ADD CONSTRAINT note_nn CHECK (note IS NOT NULL) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT note_nn; ALTER TABLE orders ALTER COLUMN note SET NOT NULL; ALTER TABLE orders DROP CONSTRAINT note_nn;
To run it, paste the lines into psql on any PostgreSQL 12 or newer. In a real project, place each of the last four statements in its own migration. Otherwise the tool wraps them in one transaction, and the lock from the first step lasts until the end. Also, do not drop the helper constraint in the same command as SET NOT NULL, as this community write-up warns, because the scan then comes back. That source is a blog post, so verify it against your version.

The result is clear. The direct route blocked the table for about 340 ms, while the safe route blocked it for roughly 1.3 ms in total. The slow validation still happened, but your queries kept running during it. One more number matters for the migrate phase: updating all three million rows in a single statement took 14.1 seconds in one run. For that reason, backfill in small batches.
Pre-deploy checklist
- Is every step compatible with the previous app version?
- Does any statement rewrite the table or scan it under a blocking lock?
- Is the backfill batched, and is the contract step planned for a later release?
- Did you set a short lock timeout, so a blocked migration fails fast instead of queuing traffic?
What We Have Learned
Zero-downtime migrations depend on two ideas. First, the expand and contract pattern keeps old and new application versions working together by splitting a risky change into expand, migrate and contract. Second, you must watch lock behavior, because a statement can be logically safe and still freeze a table. In our PostgreSQL test, a rewrite took 3.8 seconds, a direct SET NOT NULL took 340 milliseconds, and the validated-constraint route blocked for about a millisecond. Flyway and Liquibase both support this style, as long as each phase lives in its own migration and the contract step ships later.


