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:
Downmethods must not cause data loss unless acceptable.- For column drops and table drops, leave
Downempty — 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.