Skip to content

.NET Core · Entity Framework Core

Working with EF Core Migrations

5 min read Updated 2026-06-23 Share:

Practice Migrations interview questions

Why migration knowledge matters in interviews

Schema management is where EF Core projects most often break in production. Interviewers test migrations because the mistakes — running dotnet ef database update against production, auto-migrating in a multi-replica deploy, or never writing a Down method — are expensive to fix after the fact.

What migrations are

EF Core migrations are versioned C# classes that translate model changes into database schema operations. Each migration has an Up (apply) and a Down (revert) method.

// Generated by: dotnet ef migrations add AddOrderStatus
public partial class AddOrderStatus : Migration
{
    protected override void Up(MigrationBuilder mb)
    {
        mb.AddColumn<string>("Status", "Orders", "nvarchar(50)", false, "Pending");
        mb.CreateIndex("IX_Orders_Status", "Orders", "Status");
    }

    protected override void Down(MigrationBuilder mb)
    {
        mb.DropIndex("IX_Orders_Status", "Orders");
        mb.DropColumn("Status", "Orders");
    }
}

EF Core tracks applied migrations in the __EFMigrationsHistory table. MigrateAsync is idempotent — it skips migrations already in the table.

The standard workflow

# 1. Change the model (add property, entity, or relationship)

# 2. Generate the migration:
dotnet ef migrations add AddOrderStatus \
    --project MyApp.Data \
    --startup-project MyApp.Api

# 3. Review the generated Up/Down — always read it before committing

# 4. Apply to local dev DB:
dotnet ef database update

# 5. Commit migration files alongside the model change in one PR

# 6. CI applies the migration against an integration test database

# 7. Production: MigrateAsync at startup or a migration bundle in the deploy step

The key rule: commit migration files with the model change — always in the same PR.

Applying migrations in production

Two options, with different trade-offs:

Option A — MigrateAsync at startup

Simple, works for single-server deployments:

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync(); // idempotent; skips applied migrations
}

await app.RunAsync();

Problem: In a multi-replica deployment (Kubernetes, App Service), multiple instances race to apply the same migration simultaneously. Use a migration bundle instead.

Option B — Migration bundle (preferred for containers)

# Build once in CI:
dotnet ef migrations bundle \
    --self-contained \
    --runtime linux-x64 \
    -o ./migrate \
    --project MyApp.Data \
    --startup-project MyApp.Api

# Run in a Kubernetes init container or deploy step:
./migrate --connection "Server=prod;Database=App;..."
# Kubernetes init container:
initContainers:
  - name: db-migrate
    image: myapp-migrate:latest
    command: ["./migrate"]
    env:
      - name: ConnectionStrings__Default
        valueFrom:
          secretKeyRef:
            name: db-secret
            key: connection-string

The bundle runs before the application starts, guaranteeing a single migration executor.

The __EFMigrationsHistory table

-- Created automatically by EF Core:
CREATE TABLE __EFMigrationsHistory (
    MigrationId    nvarchar(150) NOT NULL PRIMARY KEY,
    ProductVersion nvarchar(32)  NOT NULL
);

MigrateAsync checks this table and skips any migration whose MigrationId is already there. Check pending migrations programmatically:

var pending = await db.Database.GetPendingMigrationsAsync();
if (pending.Any())
    logger.LogWarning("{Count} migration(s) pending", pending.Count());

Design-time factory

When AppDbContext lives in a class library, migration tooling can't discover it automatically. Create an IDesignTimeDbContextFactory<T>:

public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
    public AppDbContext CreateDbContext(string[] args)
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .AddEnvironmentVariables()
            .Build();

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(config.GetConnectionString("Default"))
            .Options;

        return new AppDbContext(options);
    }
}

Then specify both projects in the CLI:

dotnet ef migrations add InitialCreate \
    --project MyApp.Data \          # where DbContext lives
    --startup-project MyApp.Api     # where appsettings.json lives

Data seeding

Model-based seeding (static reference data)

HasData seeds become part of the migration — EF tracks them and generates InsertData / UpdateData operations automatically:

modelBuilder.Entity<Category>().HasData(
    new Category { Id = 1, Name = "Electronics", Slug = "electronics" },
    new Category { Id = 2, Name = "Clothing",    Slug = "clothing"    }
);
// dotnet ef migrations add SeedCategories generates an InsertData migration

Constraints: PKs must be set explicitly (no DB-generated values). Changing a row generates an UpdateData migration.

Custom seeder (dynamic or environment-specific data)

public static class DatabaseSeeder
{
    public static async Task SeedAsync(AppDbContext db)
    {
        if (await db.Users.AnyAsync(u => u.Email == "admin@example.com"))
            return; // idempotent

        db.Users.Add(new User
        {
            Email    = "admin@example.com",
            Role     = "Admin",
            Password = PasswordHasher.Hash("initial-password")
        });
        await db.SaveChangesAsync();
    }
}

// At startup, after MigrateAsync:
await db.Database.MigrateAsync();
await DatabaseSeeder.SeedAsync(db);

Rolling back

# Roll back to a specific migration (that migration stays applied):
dotnet ef database update AddOrderStatusColumn

# Roll back all migrations:
dotnet ef database update 0

# Remove the last unapplied migration:
dotnet ef migrations remove

Programmatic rollback:

await db.GetService<IMigrator>().MigrateAsync("20240101000000_InitialCreate");

Production rollback rules:

  • Down methods must not cause data loss unless acceptable.
  • For column drops and table drops, leave Down empty — use a forward migration to recover.
  • Always back up before rolling back in production.

Previewing the generated SQL

# Script all migrations (review before applying):
dotnet ef migrations script --idempotent

# Dry run (preview without executing):
dotnet ef database update --dry-run

Always run the idempotent script through code review before applying to staging or production.

Multiple DbContexts

When the application has more than one context, give each its own migration history:

options.UseSqlServer(conn, sql =>
    sql.MigrationsHistoryTable("__AppMigrationsHistory", "app"));
dotnet ef migrations add InitialCreate --context AppDbContext
dotnet ef migrations add IdentityInit  --context IdentityDbContext

Preventing model drift in CI

EF Core 8+ ships a command that exits with a non-zero code if the model snapshot is out of sync with the current model:

dotnet ef migrations has-pending-model-changes \
    --project MyApp.Data \
    --startup-project MyApp.Api

Add this to your CI pipeline. A model change without a migration is a production outage on the next deploy — catch it at PR time.

Squashing migrations

When the history grows unwieldy (50+ migrations), create a baseline:

# Delete all migration files
# Regenerate from the current model snapshot:
dotnet ef migrations add Baseline --ignore-changes
# Update __EFMigrationsHistory on existing DBs to point to Baseline

Coordinate with every environment before squashing — all DBs must be at the latest migration.

Recap

EF Core migrations version your database schema alongside your code. Always commit them with the model change. Use MigrateAsync for simple single-server apps; use migration bundles for containers and multi-replica deployments. Create a design-time factory when your context is in a class library. Seed static data with HasData; seed dynamic data with a custom idempotent seeder. Add dotnet ef migrations has-pending-model-changes to CI to catch drift before it reaches production.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel