A migration that runs fine on a development database with a few hundred rows can lock a production table with twenty million rows for long enough to time out every request hitting it. Zero-downtime migration isn't a single trick — it's a discipline of making schema changes in small, backward-compatible steps instead of one big leap.
The Core Problem: Code and Schema Deploy at Different Times
During a deploy, there's a window where old application code and a new schema — or new code and an old schema — are both live at once, whether that's seconds or minutes. A migration that renames a column or drops one the old code still reads from will break requests during that window, even if the migration itself runs instantly.
Expand, Migrate, Contract
The safe pattern has three phases spread across separate deploys rather than one migration file.
// Phase 1: Expand — add the new column, nullable, no default backfill yet.
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->string('billing_email')->nullable()->after('email');
});
}
Deploy application code that writes to both email and billing_email. Once that's live and the backfill has run, you can read from the new column exclusively:
UPDATE customers
SET billing_email = email
WHERE billing_email IS NULL;
Only in a later deploy, once nothing reads the old column anymore, do you contract:
// Phase 3: Contract — safe now that no running code references it.
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->dropColumn('email');
});
}
If a migration and its matching code change ship in the same deploy, you haven't eliminated the risky window — you've just made it very short and hoped nobody notices.
Renaming Is Almost Always Three Steps, Not One
A direct renameColumn looks tempting but has the same problem as a drop: the instant it runs, any already-running process referencing the old name breaks. Treat a rename as add-new, dual-write, drop-old — the same expand/contract shape as above, just for a rename instead of a removal.
Watch Table Locks on Large Tables
MySQL 8 with InnoDB handles most ADD COLUMN operations as online DDL without a full table lock, but it's not universal — adding a column with a non-null default on older configurations, changing a column's type, or adding certain indexes can still take a metadata lock that queues behind long-running queries.
ALTER TABLE customers
ADD COLUMN billing_email VARCHAR(255) NULL,
ALGORITHM=INPLACE, LOCK=NONE;
Specifying ALGORITHM=INPLACE, LOCK=NONE explicitly makes MySQL fail fast with an error if it can't honor that, rather than silently falling back to a blocking copy of the table — which is exactly the surprise you want to catch in a staging run, not production.
SHOW PROCESSLIST and check for long-running transactions before kicking off a schema change on a large table — a metadata lock queues behind anything already holding a lock on that table, and a single slow report query can stall your migration indefinitely.A Practical Deploy Checklist
- Migrations that add nullable columns or new tables are generally safe to ship alongside application code.
- Migrations that drop or rename anything go out after the code that stops depending on it, never before or alongside.
- Run migrations against a staging database with production-like volume before trusting them on the real thing.
- Keep each migration file focused on one schema change so a rollback doesn't have to unwind five unrelated things at once.
None of this is exotic — it's the same discipline teams use for any backward-incompatible API change, applied to the database instead of an endpoint. The schema is just another interface other code depends on, and it deserves the same care.