EF Core Migrations: Practical, Battle‑Tested Guide (2025)

Entity Framework Migrations: Your Ultimate Guide to Effortless Schema Changes

Have you ever pushed a hotfix and then realized the DB schema didn’t get the memo? I’ve been there: frantic rollbacks, a red CI pipeline, and a Slack channel that suddenly becomes very chatty. The good news – you can make schema changes boring again. In this guide, I’ll show you how to run Entity Framework Core (EF Core) migrations like a pro: fast, predictable, and safe.

Understanding Migrations

What are migrations?

Migrations are versioned change sets for your database schema. EF Core compares your current model (C# classes + configuration) to the last applied snapshot, then generates code to move the DB forward (and back). Think of it as Git for your schema: you commit code, migrations commit structure.

How EF Core builds a migration

  1. EF scans your DbContext model.
  2. It diffs it against the last snapshot stored in your project.
  3. It emits a C# class (the migration) with two methods:
    • Up() – apply changes
    • Down() – revert changes
  4. When you run database update, EF writes an entry into __EFMigrationsHistory so the DB knows where it is.

CLI vs PMC

You can create migrations either with the .NET CLI or Package Manager Console:

# CLI
 dotnet ef migrations add AddEmployees
 dotnet ef database update

# Remove last (if not applied)
 dotnet ef migrations remove
# PMC
Add-Migration AddEmployees
Update-Database
Remove-Migration

Tip: install tools locally in the solution folder so teammates run the same version: dotnet tool restore (paired with a dotnet-tools.json).

Managing Database Schema Changes

Setup EF Core (SQL Server example)

Add packages:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design

Wire up the context (Program.cs, minimal hosting):

var builder = WebApplication.CreateBuilder(args);

// Connection string in appsettings.json → ConnectionStrings:Default
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
       .EnableDetailedErrors()
       .EnableSensitiveDataLogging(builder.Environment.IsDevelopment()));

var app = builder.Build();
app.MapGet("/health", () => Results.Ok("ok"));
app.Run();

A minimal context and model:

public sealed class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

    public DbSet<Employee> Employees => Set<Employee>();
    public DbSet<Department> Departments => Set<Department>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Employee>(e =>
        {
            e.HasKey(x => x.Id);
            e.Property(x => x.FirstName).HasMaxLength(100).IsRequired();
            e.Property(x => x.LastName).HasMaxLength(100).IsRequired();
            e.HasOne(x => x.Department)
             .WithMany(d => d.Employees)
             .HasForeignKey(x => x.DepartmentId)
             .OnDelete(DeleteBehavior.Restrict);
        });

        modelBuilder.Entity<Department>(d =>
        {
            d.HasKey(x => x.Id);
            d.Property(x => x.Name).HasMaxLength(80).IsRequired();
        });
    }
}

public sealed class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; } = default!;
    public string LastName { get; set; } = default!;
    public int DepartmentId { get; set; }
    public Department? Department { get; set; }
}

public sealed class Department
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
    public ICollection<Employee> Employees { get; set; } = new List<Employee>();
}

Add your first migration

dotnet ef migrations add Init

This creates a folder like Migrations/20250826102034_Init.cs plus a model snapshot.

Inspect the generated code – do not mindlessly ship it. Look for risky ops (column type changes, nullability flips) and ensure Down() is meaningful.

Apply migrations

Local dev:

dotnet ef database update

In CI/CD you’ll likely use either application start-up migration or pre-start migration step (preferred). Example GitHub Actions step:

- name: Apply EF migrations
  run: |
    dotnet tool restore
    dotnet ef database update --project src/App --startup-project src/App
  env:
    ConnectionStrings__Default: ${{ secrets.DB_CONNECTION }}

Safer production deploys

  • Back up first. Automate this and link the backup ID to the release.
  • Idempotent SQL scripts for DBAs: dotnet ef migrations script --idempotent -o artifacts/migrate.sql This script can be applied from any version to the latest.
  • .NET 8 Migration bundles (single exe without SDK on the server): dotnet ef migrations bundle \ --project src/App \ --startup-project src/App \ --output artifacts/migrate.exe Then run migrate.exe --connection "..." in the pipeline.

Reading the Migration (what to check)

A typical migration contains Up/Down and operations like CreateTable, AddColumn, AlterColumn, Sql(...). Here’s a realistic sample I’d accept to ship:

public partial class AddIndexesAndHireDate : Migration
{
    protected override void Up(MigrationBuilder mb)
    {
        // new nullable column is safe
        mb.AddColumn<DateTime>(
            name: "HireDate",
            table: "Employees",
            type: "datetime2",
            nullable: true);

        // create an index to speed up lookups
        mb.CreateIndex(
            name: "IX_Employees_DepartmentId",
            table: "Employees",
            column: "DepartmentId");

        // migrate existing data if needed (always guarded)
        mb.Sql(@"UPDATE e SET HireDate = GETUTCDATE() WHERE HireDate IS NULL",
               suppressTransaction: false);
    }

    protected override void Down(MigrationBuilder mb)
    {
        mb.DropIndex("IX_Employees_DepartmentId", "Employees");
        mb.DropColumn("HireDate", "Employees");
    }
}

Smell tests before merge:

  • Are we dropping or renaming columns? Prefer two-step rename (RenameColumn) or copy data → swap to avoid loss.
  • Are we changing nullability or type? Provide a backfill or default and run-time-safe transitions.
  • Do we have long-running Sql? Consider batching to avoid table locks.

Rollbacks without drama

There are three realistic paths:

  1. Remove-Migration / dotnet ef migrations remove
    Use only if the migration wasn’t applied anywhere yet (dev-only). It simply deletes the last generated migration.
  2. Targeted downgrade
    Find a safe anchor migration and run: dotnet ef database update 20250826102034_Init Caveat: data loss if a later migration dropped/altered columns. Plan for compensating scripts.
  3. Forward fix (preferred in prod)
    Instead of downgrading, create a new migration that repairs the issue (e.g., restore column, adjust constraint). This keeps the history linear and avoids destructive flips.

Golden rule: practice on a restored production backup in a staging environment. Your rollback plan is only real once you’ve executed it.

Seeding Data (clean and environment-aware)

When and why to seed

  • Reference data (countries, default roles, feature flags) that must exist.
  • Demo/fixtures for local dev & tests.
  • Safe initial content at first deploy.

Option A: HasData (model-time seeding)

Great for small, immutable sets that rarely change.

protected override void OnModelCreating(ModelBuilder mb)
{
    mb.Entity<Department>().HasData(
        new Department { Id = 1, Name = "Engineering" },
        new Department { Id = 2, Name = "Finance" }
    );
}

Pros: declarative, tracked in snapshots, easy.
Cons: updates require new migrations; not ideal for dynamic data.

Option B: Seed inside a migration

Use InsertData / UpdateData or raw Sql:

protected override void Up(MigrationBuilder mb)
{
    mb.InsertData("Departments",
        columns: new[] { "Id", "Name"},
        values: new object[,] { { 3, "People Ops" }, { 4, "Sales" } });
}

Pros: explicit and versioned with schema.
Cons: noisy if you seed large datasets.

Option C: Runtime seeder (environment-specific)

Create a small bootstrap that runs at app start (dev/test only) and never in prod.

public static class DatabaseSeeder
{
    public static async Task SeedAsync(this IServiceProvider sp)
    {
        using var scope = sp.CreateScope();
        var env = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
        if (!env.IsDevelopment()) return; // guard!

        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync(); // ensure schema first

        if (!await db.Departments.AnyAsync())
        {
            db.Departments.AddRange(
                new Department { Id = 100, Name = "Demo" },
                new Department { Id = 200, Name = "Support" }
            );
            await db.SaveChangesAsync();
        }
    }
}

Call in Program.cs after Build() only for dev:

if (app.Environment.IsDevelopment())
{
    await app.Services.SeedAsync();
}

Seeding rules of thumb

  • Keep prod seeding minimal and deterministic.
  • Avoid inserting PII or secrets in migrations.
  • For large reference data, prefer idempotent SQL scripts checked by checksums.

Naming, Organization, and Review

  • Name migrations by intent, not emotion: AddEmployeeHireDate, RenameDeptToDepartment.
  • Keep one concern per migration. If you added columns and indexes, fine; don’t also refactor six unrelated tables.
  • Review checklists in PR:
    • Any destructive change? (drop/alter)
    • Are defaults and NOT NULL transitions safe?
    • Do we keep Down() realistic?
    • Indexes for new query paths?
  • Delete dead migrations only if they have never shipped. Once in prod, they’re history – don’t rewrite it.

CI/CD: make it boring

Option 1 – App self-migrates on start (simple, risky in prod)

Pros: zero extra step. Cons: races on multiple instances, surprises at startup time.

using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();

Option 2 – Pre-start migration job (recommended)

  • Build a migration artifact (idempotent SQL or bundle).
  • Run it once per release in the pipeline.
  • Then deploy the app binaries/containers.

Example Azure DevOps snippet (idempotent script):

- task: DotNetCoreCLI@2
  inputs:
    command: 'custom'
    custom: 'ef'
    arguments: 'migrations script --idempotent -o $(Build.ArtifactStagingDirectory)/migrate.sql \
               --project src/App --startup-project src/App'

- task: SqlDacpacDeploymentOnMachineGroup@0
  inputs:
    SqlFile: '$(Build.ArtifactStagingDirectory)/migrate.sql'
    ServerName: '$(dbServer)'
    DatabaseName: '$(dbName)'
    AuthenticationType: 'sqlAuthentication'
    SqlUserName: '$(dbUser)'
    SqlPassword: '$(dbPassword)'

Common pitfalls (and how to dodge them)

  • Renames detected as drop+add → Use RenameColumn/RenameTable or Fluent API HasColumnName during a transition migration.
  • Long locks in prod → Break large updates into batches; schedule during low-traffic windows; add WITH (ONLINE = ON) where supported (e.g., filtered indexes).
  • Conflicting migrations on feature branches → Rebase and regenerate before merge. Last writer wins on snapshot – resolve conflicts carefully.
  • Down() lies → If you can’t reliably revert, make it explicit (comment why) and plan a forward fix playbook.
  • Environment drift → Always generate idempotent scripts for ops. Verify __EFMigrationsHistory state before running.

FAQ: EF Core Migrations in practice

Do I need a migration for every tiny change?

Batch small changes into one migration per PR. Fewer, meaningful migrations are easier to reason about.

Can I hand-edit generated migrations?

Yes – carefully. I often add data fixes or index hints. Keep edits reviewable and covered by tests.

What about multiple databases/tenants?

Generate idempotent scripts and apply per-tenant. For truly isolated schemas, consider a tenant-by-tenant runner that queries history before applying.

How do I test migrations?

Spin up a throwaway DB (LocalDB/SQL Edge/PostgreSQL container), run database update, then assert structure and data with integration tests.

Are bundles safe for production?

Yes. They remove the SDK dependency and make the step deterministic. Keep connection strings out of logs and rotate on failure.

Conclusion: Migrations should be boring – make them so

If deployments feel like cliff jumps, you’re doing too much manually. With a tidy model, reviewable migrations, idempotent artifacts, and a predictable CI/CD step, schema evolution becomes routine. Start by naming migrations clearly, reviewing Up/Down like production code, and producing idempotent scripts for ops. Do this for two releases and watch on-call get quieter.

Your turn: what’s the nastiest migration problem you’ve faced – and how did you fix it? Drop a comment; I’ll add the best lessons to this playbook.

Leave a Reply

Your email address will not be published. Required fields are marked *