Learnixo
Back to blog
AI Systemsintermediate

EF Core Migrations — Managing Schema Changes

Manage EF Core migrations in production: creating and applying migrations, migration bundles, idempotent scripts, rollback strategies, data seeding, and multi-environment migration patterns.

Asma Hafeez KhanMay 16, 20265 min read
EF CoreMigrationsDatabaseASP.NET Core.NETDevOps
Share:𝕏

Migrations Workflow

1. Change domain model / configuration
2. dotnet ef migrations add 
   → creates Migrations/_.cs
3. Review generated migration — ALWAYS check before applying
4. dotnet ef database update
   → applies migration to database
5. Commit the .cs migration file to source control

Creating Migrations

Bash
# Add a migration
dotnet ef migrations add AddPatientMrnUniqueIndex \
  --project Infrastructure \
  --startup-project Api \
  --context ApplicationDbContext \
  --output-dir Persistence/Migrations

# List pending migrations
dotnet ef migrations list

# Remove the last migration (only if not applied to any DB)
dotnet ef migrations remove

# Generate SQL script (review before running)
dotnet ef migrations script --idempotent --output migrations.sql

Applying Migrations at Startup

C#
// Program.cs — auto-migrate on startup (dev/test environments)
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    await db.Database.MigrateAsync();
}

// Production: DO NOT auto-migrate at startup.
// Use migration bundles or idempotent SQL scripts run by CI/CD instead.
// Auto-migrate in production means every restart risks a schema change mid-rollout.

Migration Bundles (Production Approach)

Bash
# Build a self-contained migration binary
dotnet ef migrations bundle \
  --project Infrastructure \
  --startup-project Api \
  --output efbundle \
  --self-contained

# Run the bundle in CI/CD pipeline (no dotnet SDK required)
./efbundle --connection "Server=prod-db;..."

# Idempotent SQL script alternative
dotnet ef migrations script --idempotent --output ./migrations/apply.sql
# Run apply.sql via sqlcmd or ADO.NET in the pipeline

Data Seeding

C#
// Configuration-based seeding (applied with migrations)
public sealed class RoleConfiguration : IEntityTypeConfiguration<AppRole>
{
    public void Configure(EntityTypeBuilder<AppRole> builder)
    {
        builder.HasData(
            new AppRole { Id = Guid.Parse("..."), Name = "Clinician",     NormalizedName = "CLINICIAN" },
            new AppRole { Id = Guid.Parse("..."), Name = "Pharmacist",    NormalizedName = "PHARMACIST" },
            new AppRole { Id = Guid.Parse("..."), Name = "Administrator", NormalizedName = "ADMINISTRATOR" }
        );
    }
}

// HasData uses fixed IDs — required for idempotent seeding.
// Avoid auto-generated IDs in HasData — each migration regeneration changes them.

Custom Migration Operations

C#
// Add data migration inside a schema migration
public partial class AddPatientMrnColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name:      "mrn",
            table:     "patients",
            maxLength: 20,
            nullable:  true);   // nullable first — then populate — then make required

        // Back-fill existing rows
        migrationBuilder.Sql(@"
            UPDATE patients
            SET mrn = CONCAT('MRN-', CAST(id AS NVARCHAR(36)))
            WHERE mrn IS NULL");

        // Now make the column required
        migrationBuilder.AlterColumn<string>(
            name:      "mrn",
            table:     "patients",
            maxLength: 20,
            nullable:  false,
            oldClrType: typeof(string),
            oldMaxLength: 20,
            oldNullable: true);

        migrationBuilder.CreateIndex(
            name:    "ix_patients_mrn",
            table:   "patients",
            column:  "mrn",
            unique:  true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropIndex(name: "ix_patients_mrn", table: "patients");
        migrationBuilder.DropColumn(name: "mrn", table: "patients");
    }
}

Rollback Strategy

EF Core has no built-in rollback — Down() is a manual operation.

Option A: Down() migration (revert to previous state)
  dotnet ef database update 
  Risk: destructive if Down() drops columns with data

Option B: Forward rollback (preferred for production)
  Add a new migration that reverts the unwanted change.
  Never destroy data — add nullable columns, rename safely.

Option C: Point-in-time restore (database backup)
  For catastrophic failures — restore the DB from backup.
  Requires taking a backup before applying migrations in production.

Production rule: migrations should always be backward-compatible
  with the previous version of the application code.

Multi-Environment Migration Pattern

C#
// Apply migrations only in specific environments
if (app.Environment.IsDevelopment() || app.Environment.IsStaging())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    await db.Database.MigrateAsync();
}

// Production: CI/CD pipeline runs the migration bundle before deploying
// This ensures migration completes before new code is active.

Production issue I've seen: A team added a NOT NULL column with no default to a 2M-row table. The migration added the column, and because SQL Server had to update every row, the migration held an exclusive table lock for 4 minutes. During that time, the application threw exceptions on every request that touched that table. Fix: add the column as nullable → backfill data → then make it required in a separate migration. Never add a NOT NULL column without a default to a large table in one step.


Red Flag / Green Answer

Red Flag: "We call Database.MigrateAsync() in Program.cs on startup in production so the database is always up to date."

Auto-migrating in production means every deployment and every pod restart can trigger a schema change. Blue-green deployments fail because the new pods migrate the schema before the old pods stop. If the migration fails mid-way, the database is partially migrated and the application is broken. Use migration bundles or idempotent SQL scripts run as a pre-deployment step.

Green Answer:

Migrations run in CI/CD as a pre-deployment step using dotnet ef migrations bundle or --idempotent SQL scripts. Auto-migrate is only enabled in development and staging. Production schema changes are reviewed and approved before deployment. Migrations are always backward-compatible with the previous application version.


Key Takeaway

Migrations are the source of truth for schema changes — always commit them. For production: use migration bundles or idempotent SQL scripts in CI/CD, not MigrateAsync() on startup. Adding NOT NULL columns to large tables requires a three-step migration: add nullable → backfill → make required. Always review generated migration SQL before applying — EF Core does not always generate what you expect.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.