Database migrations as a first-class concern
·2 min read ·Databases · DevOps · Best Practices
Most developers think of database migrations as a formality—you write them, you run them, they work. And in development, they usually do. Production is different. Production has data in it.
The Mistakes That Break Production
Renaming a column while the app is running. Your code expects user_name, your database now has username. Every request that touches that column fails until the deploy completes.
Adding a NOT NULL column without a default to a table with existing rows. The migration fails. You get an error at 2 AM.
Dropping a column the current codebase still reads.
// This breaks immediately if rows exist with no default:
Schema::table('users', function (Blueprint $table) {
$table->string('display_name')->notNullable(); // No default!
});
// This is safe:
Schema::table('users', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('email');
});
The Expand/Contract Pattern
For zero-downtime schema changes, use two deployments:
- Expand: Add the new column (nullable), deploy code that writes to both old and new columns
- Contract: Backfill old rows, deploy code that only uses new column, drop old column
This is more work. It's also the only way to rename a column on a live table without downtime.
Write Down Migrations
Before you write a migration, answer:
- What happens if this fails halfway through?
- Does this work on a table with 10 million existing rows?
- Can I roll this back if the deploy goes wrong?
If the answer to any of these is 'I don't know,' figure it out before you write the migration, not after you've pushed it to production.