Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Zero-Downtime Database Migrations: The Expand and Contract Pattern

About Post

Renaming a column is one line of code. Deployed the naive way, it's also a short outage you scheduled yourself.

The migration runs, the column is now called mobile_number, and for the next few seconds (or minutes) the code still running in production keeps asking for phone. Requests fail, queued jobs fail, and somebody's tenant app shows a friendly error screen. Nothing was wrong with the migration. The problem was the order of things.

The fix is a pattern with a slightly grand name: expand and contract.

Why deploys aren't atomic

We like to imagine a deploy as a switch: old version off, new version on. In reality there's always a window where old code and the new database schema meet:

  • Migrations usually run before the new code goes live, so old code runs against the new schema for a while.
  • With several servers behind a load balancer, they don't all switch at the same instant.
  • Queue workers keep the old code in memory until they're restarted, and jobs already in the queue were created by the old code.
  • If something goes wrong, you'll want to roll the code back, and the old code needs to work with whatever schema is there.

So the real rule is: every schema change must be compatible with both the version of the code before it and the version after it. Expand and contract is just a disciplined way of following that rule.

The pattern, step by step

Let's rename tenants.phone to tenants.mobile_number without anyone noticing. It takes several small deploys instead of one big one.

Step 1: Expand (add the new column, nullable)

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('tenants', function (Blueprint $table) {
            $table->string('mobile_number', 20)->nullable()->after('phone');
        });
    }
};

Old code doesn't know the column exists, and doesn't care. Nullable is important: a NOT NULL column without a default would break every insert the old code makes.

Step 2: Write to both

Deploy code that writes to both columns but still reads the old one. A small model hook keeps it in one place:

protected static function booted(): void
{
    static::saving(function (Tenant $tenant) {
        if ($tenant->isDirty('phone')) {
            $tenant->mobile_number = $tenant->phone;
        }
    });
}

From now on, every new or updated row has both values. Note that this only covers writes through Eloquent; if you have raw queries or bulk updates touching the column, they need the same treatment.

Step 3: Backfill the old rows

DB::table('tenants')
    ->whereNull('mobile_number')
    ->whereNotNull('phone')
    ->chunkById(500, function ($tenants) {
        foreach ($tenants as $tenant) {
            DB::table('tenants')
                ->where('id', $tenant->id)
                ->update(['mobile_number' => $tenant->phone]);
        }
    });

Run it as an Artisan command or a queued job, not inside the migration. Small batches keep each write short, so you don't hold locks for long or flood replication. Use chunkById, not chunk: when you update the same column you're filtering on, offset-based chunking skips rows.

Step 4: Switch reads

Deploy code that reads mobile_number everywhere: models, API resources, exports, reports. Keep writing both columns for one more release, so a rollback is still safe. The hook simply flips direction: the code now sets mobile_number, and the hook copies it back into phone.

Step 5: Contract (remove the old column)

Once nothing reads or writes phone, remove the dual-write hook in one deploy, then drop the column in the next:

Schema::table('tenants', function (Blueprint $table) {
    $table->dropColumn('phone');
});

Waiting a release before dropping feels slow. It's the cheapest insurance you'll ever buy: if the release that switched reads has a problem, you can roll back without restoring data.

The rule: add before you use, stop using before you remove. Every step is boring on its own, and boring is exactly what you want from a production deploy.

The migrations that deserve a second look

Not every change needs five deploys. Adding a new nullable column for a new feature is already safe. These are the ones that deserve a pause before you merge:

ChangeWhy it's riskySafer approach
Rename a column or tableOld code still uses the old nameExpand and contract
Drop a columnOld code (and old queued jobs) may still read or write itRemove from code first, drop a release later
Add a NOT NULL column with no defaultOld code's inserts failAdd nullable, backfill, then tighten
Change a column's typeMySQL often rebuilds the whole table and blocks writes while it doesNew column plus backfill, or an online schema tool
Add an index to a large tableUsually online in InnoDB, but still heavy I/O and replica lagRun off-peak, watch replication
Add a unique constraintFails halfway if duplicates existFind and fix duplicates first

A MySQL detail worth knowing

In MySQL 8, many changes are fast at the database level. Adding a column can be INSTANT (a metadata change, no table rebuild), and renaming a column doesn't copy the table either. That's good news, but it's also why people get caught out: the database part was quick, so they assumed the deploy was safe. The outage came from the code, not the lock.

The opposite surprise also happens: a change you expected to be quick quietly falls back to copying the whole table. If you want a migration to fail loudly instead, ask for the algorithm explicitly:

DB::statement(
    'ALTER TABLE tenants ADD COLUMN mobile_number VARCHAR(20) NULL, ALGORITHM=INSTANT'
);

If MySQL can't do it instantly, it refuses with an error instead of locking your table. For very large tables where a copy is unavoidable, tools like gh-ost or pt-online-schema-change build the new table in the background and swap it in.

The checklist I run before merging a migration

  • Will the currently deployed code still work after this migration runs?
  • If I roll back the code (not the database), does the old version still work?
  • Do queued jobs created by the old code still work?
  • Does this rebuild or lock a large table? Have I checked the table size?
  • Is the backfill a separate, batched, restartable step?

If the answer to any of those is "not sure", that's a sign to split the change into smaller deploys.

What's your team's rule for risky migrations? Do you split them into multiple deploys, or schedule a maintenance window and accept the downtime?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close