Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Database Migrations at Scale Zero Downtime

By Kokil Thapa | Last reviewed: September 2026

Database Migrations at Scale Zero Downtime fail when teams treat schema changes like local dev work. A single blocking ALTER TABLE on a busy orders table can lock writes for minutes. That stalls checkout, booking, and payment callbacks. On production Laravel apps I maintain, schema work is planned like a release. You pair Laravel zero-downtime migration patterns with deploy order, feature flags, and rollback paths. This guide covers what actually works at scale in 2026.

What makes Database Migrations at Scale Zero Downtime different from local migrations?

Local migrations run against empty or tiny datasets. Production tables may hold millions of rows. MySQL 9.7 and PostgreSQL 18 handle DDL differently. A rename that takes 200 ms locally can lock a hot index for 20 minutes under load.

Zero downtime means two things. Users keep using the app. No data is lost if you roll back the application layer. Schema and code must move in compatible steps across multiple deploys.

I treat every production migration as a three-layer change: database schema, application code, and background jobs. Skip any layer and you get 500 errors, silent data drift, or stuck queues. That pattern shows up on booking platforms with live reservation data and on eCommerce carts where order state must stay consistent.

Expand-Contract Migration PhasesExpandAdd nullable colsDeployDual-write codeBackfillQueue job syncContractDrop old colsEach phase = separate deploy + rollback planApp reads old column until backfill completesApp writes both columns during dual-write windowContract only after 100% rows migrated + verifiedNever combine expand and contract in one release
Database Migrations at Scale Zero Downtime follow expand-contract across multiple deploys, not one big ALTER.

Blocking vs non-blocking operations

Some DDL operations rebuild the entire table. Others add metadata only. Know which category your change falls into before you schedule a deploy window.

OperationMySQL 9.7PostgreSQL 18Zero-downtime safe?
Add nullable columnInstant (InnoDB)Fast metadata changeYes — expand phase
Add NOT NULL + defaultMay rebuild tableCan rewrite tableOnly with backfill plan
Rename columnBlocking rebuildBlocking in many casesNo — use add-copy-drop
Add index (online)ALGORITHM=INPLACECREATE INDEX CONCURRENTLYYes — monitor lock wait
Drop columnInstant metadata (8.0+)Fast dropYes — contract phase only
Change column typeOften full rebuildOften full rewriteNo — new column + backfill

When in doubt, assume blocking. Plan expand-contract even if docs say the operation is instant. Replication lag and long transactions can still stall your app.

How do you run Laravel migrations without downtime in production?

Laravel 13.x runs migrations through Artisan. The default php artisan migrate --force inside a deploy hook is fine for additive changes. It is dangerous for destructive ones. I split migration files by risk class and run them at different pipeline stages.

On Deployer 7 pipelines I maintain, the sequence looks like this:

  1. Deploy new code with backward-compatible schema (expand migrations only).
  2. Run expand migrations before switching the symlink.
  3. Reload PHP-FPM so opcache picks up new code.
  4. Run backfill jobs via queue workers.
  5. Schedule contract migrations in a later release after verification.

This mirrors the workflow described in zero-downtime Laravel deployment with Deployer. Migration timing matters as much as symlink swaps.

Example: renaming a column safely

Never use $table->renameColumn() on a live table with traffic. Use four migrations across two or three releases instead.

/* Migration 1 — expand: add new column */
Schema::table('orders', function (Blueprint $table) {
    $table->string('customer_email')->nullable()->after('email');
});

/* Migration 2 — deploy code that writes BOTH email and customer_email */
/* In Order model observer or saving hook: */
public function saving(Order $order): void
{
    if ($order->isDirty('email')) {
        $order->customer_email = $order->email;
    }
}

/* Migration 3 — backfill command (chunked) */
Order::whereNull('customer_email')
    ->whereNotNull('email')
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            $order->update(['customer_email' => $order->email]);
        }
    });

/* Migration 4 — contract: drop old column (later release) */
Schema::table('orders', function (Blueprint $table) {
    $table->dropColumn('email');
});

Chunk size depends on row width and index count. Start at 500 rows. Watch InnoDB history list length and replication delay. Increase slowly if the job finishes fast with low CPU.

For larger teams, coordinate through migration workflows in team environments so two developers do not ship conflicting expand and contract steps in the same sprint.

Which MySQL and PostgreSQL settings matter for online DDL at scale?

Engine choice and lock settings decide whether your migration runs in seconds or stalls checkout. Tune these before your first large-table change.

MySQL 9.7 online DDL

Always inspect the migration plan before running it on production. MySQL exposes this through ALGORITHM and LOCK hints.

ALTER TABLE orders
    ADD INDEX idx_status_created (status, created_at),
    ALGORITHM=INPLACE, LOCK=NONE;

Run a dry check first:

ALTER TABLE orders
    ADD INDEX idx_status_created (status, created_at),
    ALGORITHM=INPLACE, LOCK=NONE,
    ALGORITHM=INPLACE;

Review the output. If MySQL reports COPY instead of INPLACE, stop. Redesign the migration. The official MySQL InnoDB online DDL documentation lists which operations support inplace algorithms.

On managed hosting or VPS servers, also watch disk I/O during index builds. A secondary index on a 50 GB table can saturate IOPS and slow every query. Schedule heavy index builds off-peak if your provider lacks burst capacity.

PostgreSQL 18 concurrent indexes

Standard CREATE INDEX takes a share lock that blocks writes. Use concurrent mode for production tables with live traffic.

CREATE INDEX CONCURRENTLY idx_orders_status
    ON orders (status)
    WHERE deleted_at IS NULL;

Concurrent index builds cannot run inside a Laravel migration transaction. Use DB::unprepared() or a raw statement migration. Failed concurrent builds leave an invalid index. Drop it and retry. See the PostgreSQL CREATE INDEX docs for failure recovery steps.

Zero-Downtime Deploy + Migration OrderGitLab CILint + testsExpand DDLBefore symlinkSymlinkSwap releasePHP-FPMReload opcacheQueue workers pick up backfill jobsVerify row countsChecksum sampleContract DDLNext release onlyRollback = revert symlink; old code still reads old schema
Production deploy pipelines run expand migrations before the symlink swap and delay contract migrations until backfill verification passes.

How do you backfill millions of rows without overloading the database?

Backfill is where zero-downtime migrations succeed or fail. A naive UPDATE orders SET … on 10 million rows locks pages and fills redo logs. Use chunked queue jobs with throttling instead.

Chunked job pattern in Laravel

class BackfillCustomerEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $startId, public int $endId) {}

    public function handle(): void
    {
        Order::whereBetween('id', [$this->startId, $this->endId])
            ->whereNull('customer_email')
            ->update(['customer_email' => DB::raw('email')]);
    }
}

Dispatch jobs in ID ranges. Limit concurrent workers during peak hours. I often cap backfill workers at two during business hours and raise to eight overnight. That protects p95 latency on read-heavy pages like product listings and lawyer directory search.

Track progress in a dedicated table or Redis key. Operators need a dashboard answer to "how far along is the backfill?" without SSH access. A simple JSON progress payload logged to your monitoring stack is enough for small teams.

Verification before contract

Never drop the old column until counts match. Run checks like these:

  • SELECT COUNT(*) FROM orders WHERE email IS NOT NULL AND customer_email IS NULL must return zero.
  • Sample 1,000 random rows and compare old vs new values.
  • Compare aggregate checksums if columns feed financial reports.

Automate these checks in CI or a post-deploy script. Manual spot checks miss edge cases. I have seen NULL defaults silently corrupt invoice totals on a legal-tech portal until a month-end report caught it.

Before any large backfill, confirm your backup restore process works. Database restore testing you should actually do is not optional when contract migrations drop columns that held billing data.

Risky vs Safe Migration ApproachSingle-step (risky)ALTER rename columnFull table rebuildWrite lock during deployRollback needs restoreDowntime: minutes to hoursCheckout errors spikeExpand-contract (safe)Add column, instant DDLDual-write app codeChunked backfill jobsSymlink rollback worksDowntime: zero for usersTraffic stays onlineSafe path costs extra deploys but avoids emergency rollbacks
Single-step ALTER renames block production traffic; expand-contract Database Migrations at Scale Zero Downtime keep the app online.

What rollback and monitoring steps prevent a migration from becoming an outage?

Zero-downtime migration planning includes a rollback story for every phase. If expand migration succeeds but new code crashes, you revert the symlink. Old code must still work against the expanded schema. That means new columns stay nullable and unused until contract.

Pre-migration checklist

  1. Take a verified backup or snapshot. Confirm restore works on staging.
  2. Run migration against a staging copy with production-scale row counts.
  3. Measure migration duration and lock wait under simulated load.
  4. Document expected row counts before and after backfill.
  5. Alert on replication lag, lock wait timeout, and queue depth.
  6. Prepare a kill switch feature flag for code reading the new column.

Automated backups through packages like those covered in Laravel Spatie automated database backups give you a safety net. They do not replace staging rehearsal.

Monitoring during migration

Watch these metrics during expand and backfill phases:

  • Lock wait time — spikes mean DDL is blocking writers.
  • Replication lag — critical if you use read replicas for Laravel.
  • Queue latency — backfill jobs starving user-facing jobs is a common mistake.
  • Error rate — 500s after deploy often mean code/schema mismatch.

On Ubuntu servers I administer, I also watch disk space during index builds. Temp tables and sort buffers can consume tens of gigabytes. Low disk triggers MySQL crashes that look like application bugs. Linux server administration and migration planning belong in the same conversation.

Long-running migrations interact with Laravel database transactions and deadlocks. Keep backfill updates short. Avoid wrapping large batches in explicit transactions that hold row locks across seconds.

Migration Risk Decision TreeNew migration ready?Drops or renames column?YesContract phaseSeparate releaseNoChanges column type?Add + backfillNever ALTER typeAdd nullable colSafe expand deployClassify every migration before merge to main
Classify each migration as expand, backfill, or contract before merging to avoid blocking DDL in production deploys.

Index additions at scale

Missing indexes hurt query performance. Adding them wrong hurts availability. Follow database indexing for performance guidelines, but add indexes in their own deploy when tables exceed a few million rows.

On a client booking system, we added a composite index on (status, tour_date) during an off-peak window. We used online DDL on MySQL 8.4 LTS hosting with LOCK=NONE. Query time on the admin dashboard dropped from 4 seconds to 40 ms. The migration itself took 12 minutes but did not block writes.

Schema design mistakes that complicate later migrations

Some zero-downtime pain is self-inflicted at design time. Polymorphic columns without indexes, oversized JSON blobs queried in WHERE clauses, and enum columns that change weekly all make expand-contract harder. Review common database schema design mistakes before your tables reach scale.

For enterprise systems with strict uptime SLAs, factor migration strategy into architecture reviews. Enterprise application development should include a migration playbook, not just ER diagrams.

How do read replicas, connection pooling, and multi-service setups change the plan?

Once you add read replicas or split services, migration compatibility spans more than one codebase. A column drop in the monolith can break a reporting microservice that still SELECTs the old name.

Read replicas apply DDL from the primary. Large index builds on the primary create replication lag. Laggy replicas serve stale data. User profiles, order totals, and inventory counts look wrong until lag clears. Pause non-critical reads or route reporting queries to the primary during heavy DDL if lag exceeds your SLA.

Database connection pooling does not fix lock contention. It can amplify it if hundreds of pooled connections hit a table mid-migration. Reduce pool size temporarily during contract phases if you see lock wait timeouts spike.

Teams moving toward service splits should read monolith to microservices migration strategy for Laravel. Schema ownership per service means contract migrations need cross-team sign-off.

On legal-tech portals like Mijar Law Associates, document tables and payment records cannot afford downtime during schema changes. Dual-write windows and verified backfills are standard practice, not nice-to-haves.

Symfony and Doctrine projects follow the same expand-contract rules. See Symfony migrations with Doctrine best practices for PHP 8.5 and Symfony 8.1 contexts where migration classes differ but the deploy order stays identical.

After contract migrations ship, schedule ongoing support. Support and maintenance retainers should include quarterly migration reviews so technical debt does not accumulate into the next emergency rebuild.

For broader seeding and testing context, see database migrations and seeding best practices in Laravel. Staging data volume should mirror production cardinality even if row content is anonymised.

Key Takeaways

  • Never rename, retype, or drop live columns in a single deploy—use expand-contract across two or more releases.
  • Run expand migrations before the symlink swap; schedule contract migrations only after backfill verification passes.
  • Backfill in chunked queue jobs with throttling; never run one massive UPDATE on millions of rows during peak traffic.
  • Use MySQL ALGORITHM=INPLACE, LOCK=NONE and PostgreSQL CREATE INDEX CONCURRENTLY for online index builds.
  • Keep expanded columns nullable so symlink rollback to old code still works without schema restore.
  • Monitor lock wait, replication lag, and queue depth during every production migration window.

People Also Ask

Can Laravel migrations run automatically during zero-downtime deploys?

Yes, but only for expand-phase migrations that add nullable columns or online indexes. Destructive migrations should run in a separate pipeline stage with explicit approval. Automate expand steps in Deployer or GitLab CI. Keep contract migrations manual until backfill metrics pass.

How long should you wait between expand and contract migrations?

Wait until backfill reaches 100% and runs clean for at least one full business cycle. For weekly billing systems, that means seven days minimum. Rushing contract drops the old column while straggler rows or async jobs still reference it.

Does zero-downtime migration work on shared hosting?

Partially. Shared hosts often restrict long-running DDL and lack replica control. Additive migrations usually work. Large index builds and backfills may hit CPU or time limits. VPS or dedicated servers with MySQL 8.4 LTS or MySQL 9.7 give you the control zero-downtime work requires.

What is the biggest mistake teams make with production migrations?

Shipping code and schema changes in one atomic deploy without a backward-compatible window. The app expects a new column that does not exist yet, or old code crashes against a dropped column. Expand-contract exists specifically to prevent that mismatch.

Ship schema changes without taking the site offline

Database Migrations at Scale Zero Downtime are a discipline, not a Composer flag. Plan expand and contract phases, chunk your backfills, verify before you drop, and treat every blocking DDL as a production incident waiting to happen. The patterns here work on Laravel 13.x with PHP 8.3+, MySQL 9.7, and PostgreSQL 18—the stacks I deploy and maintain on client projects in Nepal and abroad.

If your application has outgrown casual php artisan migrate runs on live traffic, contact us for a migration audit. We can map your next schema change to a safe multi-release plan before it blocks your users.

Frequently Asked Questions

It means users keep using the app while schema changes roll out, and rolling back application code does not lose data. Schema and code move in compatible steps across multiple deploys, not one blocking ALTER.

Add new columns or tables first, deploy dual-reading code, backfill data, then drop old structures in a later release. Never rename, retype, or drop live columns in a single deploy.

Local migrations hit empty or tiny datasets. Production tables may hold millions of rows. A rename that takes 200 ms locally can lock a hot index for 20 minutes under load on MySQL 9.7 or PostgreSQL 18. Zero downtime requires treating every production migration as a three-layer change: database schema, application code, and background jobs. Skip any layer and you get 500 errors, silent data drift, or stuck queues.

Yes, but only for expand-phase migrations that add nullable columns or online indexes. Destructive contract migrations should run in a separate pipeline stage after backfill verification passes.

Never use renameColumn() on a live table with traffic. Use four steps across two or three releases: add the new nullable column, deploy code that writes both old and new columns via a model observer, backfill with chunkById starting at 500 rows, then drop the old column only after verification. On booking platforms and eCommerce carts I've maintained, this dual-write window keeps order state consistent while the backfill catches up.

Adding nullable columns is safe in the expand phase on both engines. Online index builds work with MySQL ALGORITHM=INPLACE, LOCK=NONE and PostgreSQL CREATE INDEX CONCURRENTLY. Dropping columns belongs in the contract phase only. Renaming columns, changing column types, and adding NOT NULL with defaults often trigger full table rebuilds and are not safe in one deploy. When in doubt, assume blocking and plan expand-contract even if documentation says the operation is instant.

Never run one massive UPDATE on millions of rows during peak traffic. Dispatch chunked queue jobs by ID range instead, limiting concurrent workers to two during business hours and raising to eight overnight. Track progress in a dedicated table or Redis key so operators know how far along the backfill is without SSH access. Watch InnoDB history list length and replication delay. Increase chunk size slowly only if jobs finish fast with low CPU and p95 latency stays stable on read-heavy pages.

Run expand migrations before switching the symlink, then reload PHP-FPM so opcache picks up new code. Run backfill jobs via queue workers after deploy. Schedule contract migrations in a later release only after verification passes. Migration timing matters as much as symlink swaps. If expand succeeds but new code crashes, reverting the symlink still works because expanded columns stay nullable and unused until contract.

On MySQL 9.7, use ALGORITHM=INPLACE, LOCK=NONE and inspect the migration plan first. If MySQL reports COPY instead of INPLACE, stop and redesign. On PostgreSQL 18, use CREATE INDEX CONCURRENTLY via DB::unprepared() because concurrent builds cannot run inside a Laravel migration transaction. Failed concurrent builds leave an invalid index that must be dropped and retried. Schedule heavy index builds off-peak on VPS or managed hosting where disk I/O can saturate during a 50 GB table build.

Engine choice and lock settings decide whether your migration runs in seconds or stalls checkout. Always run a dry check with ALGORITHM=INPLACE before production execution. Review whether MySQL reports INPLACE or COPY. On managed hosting, watch disk I/O during index builds because a secondary index on a large table can saturate IOPS and slow every query. Replication lag and long transactions can still stall your app even when docs say an operation is instant.

Run automated checks, not manual spot checks. Confirm SELECT COUNT(*) WHERE old column IS NOT NULL AND new column IS NULL returns zero. Sample random rows and compare old versus new values. Compare aggregate checksums if columns feed financial reports. Automate these in CI or a post-deploy script. I have seen NULL defaults silently corrupt invoice totals on a legal-tech portal until a month-end report caught it. Before any large backfill, confirm your backup restore process actually works.

Take a verified backup or snapshot and confirm restore works on staging before production. Run the migration against a staging copy with production-scale row counts and measure duration under simulated load. If expand migration succeeds but new code crashes, revert the symlink. Old code must still work against the expanded schema, so new columns stay nullable until contract. Prepare a kill switch feature flag for code reading the new column. Automated backups through packages like Spatie database backups give a safety net but do not replace staging rehearsal.

Watch lock wait time, replication lag, queue latency, and error rate. Lock wait spikes mean DDL is blocking writers. Replication lag matters if Laravel uses read replicas. Queue latency catches backfill jobs starving user-facing work. Five hundreds after deploy often mean code and schema mismatch. On Ubuntu servers I administer, I also watch disk space during index builds because temp tables and sort buffers can consume tens of gigabytes and trigger MySQL crashes that look like application bugs.

Read replicas apply DDL from the primary. Large index builds on the primary create replication lag, and laggy replicas serve stale profiles, order totals, and inventory counts until lag clears. Pause non-critical reads or route reporting queries to the primary during heavy DDL if lag exceeds your SLA. Database connection pooling does not fix lock contention and can amplify it if hundreds of pooled connections hit a table mid-migration. Reduce pool size temporarily during contract phases if lock wait timeouts spike.

Polymorphic columns without indexes, oversized JSON blobs queried in WHERE clauses, and enum columns that change weekly all make expand-contract harder. Some zero-downtime pain is self-inflicted at design time. For enterprise systems with strict uptime SLAs, factor migration strategy into architecture reviews alongside ER diagrams. Once you add read replicas or split services, a column drop in the monolith can break a reporting microservice still selecting the old name, so contract migrations need cross-team sign-off.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: