Database Migrations — Safe Schema Changes in Production

Database migrations in production are the highest-risk operation in a web application — a migration that locks a table can take the site down, and a bad migration that drops a column cannot be undone (the data is gone). The safe approach: always write backward-compatible migrations (the old API version still works while the migration is running), run migrations before deploying the new API version, and always have a tested rollback plan. The three-phase migration pattern handles zero-downtime column changes.

Safe Production Migrations

// ── The three-phase migration pattern for zero-downtime column changes ─────

// SCENARIO: Rename Listing.ContactPhone to Listing.PhoneNumber

// ── PHASE 1: Add new column (backward compatible — old API ignores new column) ──
// New migration: AddPhoneNumberToListings
public partial class AddPhoneNumberToListings : Migration
{
    protected override void Up(MigrationBuilder b)
    {
        b.AddColumn<string>("PhoneNumber", "Listings", nullable: true, maxLength: 20);

        // Copy existing data
        b.Sql("UPDATE Listings SET PhoneNumber = ContactPhone WHERE ContactPhone IS NOT NULL");
    }

    protected override void Down(MigrationBuilder b)
        => b.DropColumn("PhoneNumber", "Listings");
}
// DEPLOY PHASE 1: Run migration → deploy old API (reads ContactPhone) + writes to both
// Note: new API version writes to BOTH columns during transition

// ── PHASE 2: Deploy new API that reads PhoneNumber, writes to both ─────────
// Listing entity now uses PhoneNumber; EF Core still maps ContactPhone as shadow property
// Old API instances still run reading ContactPhone — still works, data is there

// ── PHASE 3: Drop old column (after all instances run new code) ────────────
// New migration: RemoveContactPhoneFromListings
public partial class RemoveContactPhoneFromListings : Migration
{
    protected override void Up(MigrationBuilder b)
        => b.DropColumn("ContactPhone", "Listings");

    protected override void Down(MigrationBuilder b)
    {
        b.AddColumn<string>("ContactPhone", "Listings", nullable: true, maxLength: 20);
        b.Sql("UPDATE Listings SET ContactPhone = PhoneNumber");
    }
}

// ── Production migration execution ────────────────────────────────────────
// In the GitHub Actions deploy workflow:
// - name: Apply EF Core migrations
//   run: |
//     dotnet tool install -g dotnet-ef
//     dotnet ef database update \
//       --project ClassifiedApp.Infrastructure \
//       --startup-project ClassifiedApp.Api \
//       --connection "${{ secrets.PROD_DB_CONNECTION }}"

// ── Emergency rollback ────────────────────────────────────────────────────
// If Phase 1 migration causes issues, roll back to previous migration:
// dotnet ef database update AddPreviousMigrationName \
//   --connection "${{ secrets.PROD_DB_CONNECTION }}"
// Note: Only works if the Down() method is implemented correctly
// For data-destructive migrations (DROP COLUMN), rollback is impossible — prevention is key

// ── Seed data as migrations ────────────────────────────────────────────────
public partial class SeedInitialCategories : Migration
{
    protected override void Up(MigrationBuilder b)
    {
        b.InsertData("Categories", ["Id", "Name", "Slug"], [
            [1, "Electronics",    "electronics"],
            [2, "Vehicles",       "vehicles"],
            [3, "Sports & Leisure", "sports-leisure"],
            [4, "Home & Garden",  "home-garden"],
            [5, "Musical Instruments", "musical-instruments"],
        ]);
    }

    protected override void Down(MigrationBuilder b)
        => b.DeleteData("Categories", "Id", [1, 2, 3, 4, 5]);
}
Note: The three-phase migration pattern (add new → transition period → drop old) guarantees zero downtime for column renames and type changes. Phase 1 and Phase 3 are individual deployments with time between them — ensuring no old API instances are running when the old column is dropped. Never rename a column in a single migration deployed with the application — the old API instances (still running during the slot swap warmup) will immediately fail trying to read a column that no longer exists.
Tip: Implement all seed data (initial categories, default roles, admin user) as EF Core migrations rather than application startup code. Startup seeding (calling db.Database.EnsureCreatedAsync() in Program.cs) runs on every app start and requires careful idempotency handling. Migration-based seeding runs exactly once per environment when the migration is applied — deterministic, versioned, and integrated with the deployment pipeline. Use MigrationBuilder.InsertData() for reference data that should be present in all environments.
Warning: Long-running migrations on SQL Azure can cause timeouts and table locks that affect live queries. SQL Server row-level locking means an ALTER TABLE that requires rebuilding an index on a 10-million-row table can lock the table for minutes. Use Azure SQL’s online index operations where available (ONLINE = ON), add indexes as separate migrations from the schema changes they support, and run large data backfill migrations during low-traffic windows with a progress-monitoring query running in parallel.

Common Mistakes

Mistake 1 — Destructive one-phase migration (breaking running instances)

❌ Wrong — DROP COLUMN ContactPhone and add PhoneNumber in one migration; old running instances crash immediately (can’t find ContactPhone).

✅ Correct — three-phase approach; old instances continue working throughout the transition period.

Mistake 2 — No Down() method on migrations (rollback impossible)

❌ Wrong — protected override void Down(MigrationBuilder b) { } empty body; bad migration cannot be rolled back.

✅ Correct — implement Down() for every Up(); test the Down() method in staging before production deployment.

🧠 Test Yourself

A migration adds a non-nullable column Category to Listings without a default value. The table has 50,000 existing rows. What happens when the migration runs?