Software Development

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.

PhaseDatabase stepApplication step
ExpandAdd the new column, nullableDeploy code that writes to both columns
MigrateBackfill old rows in small batchesSwitch reads to the new column
ContractAdd constraints, then drop the old columnRemove 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.

TopicFlywayLiquibase
Unit of changeVersioned SQL file, such as V3__add_client.sqlChangeset in a changelog
Fixing a mistakeAdd a new versioned migrationAdd a new changeset, or use the rollback attribute
TransactionsOne migration per fileOne transaction per changeset by default
Good fit forPlain SQL teamsTeams 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.

PostgreSQL 16.15 on Ubuntu 24.04 in a sandbox container, warm cache, no concurrent traffic. Medians of three repeats for the type change, SET NOT NULL, NOT VALID and VALIDATE; the other three are single runs. Absolute times will differ on your hardware and table size.

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.

Blocking time is ADD CONSTRAINT NOT VALID plus SET NOT NULL (0.79 ms + 0.55 ms). Validation took a median of 188.5 ms but runs under a lock that allows normal reads and writes.

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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button