EF Core Migrations Best Practices: From Development to Production
EF Core migrations bridge the gap between your C# model and the database schema. They work brilliantly in development, but getting them right for team workflows and production deployments requires discipline. Here are the practices that keep migrations manageable as your project grows.
Always Review Generated SQL
Never apply a migration blindly. Generate the SQL script first and review it:
dotnet ef migrations script --idempotent -o migration.sql
The --idempotent flag wraps each migration in a check, making it safe to run multiple times. Review the output for unexpected DROP statements, missing indexes, or data loss risks.
For a specific migration range:
dotnet ef migrations script PreviousMigration CurrentMigration -o migration.sql
Use Migration Bundles for Production
Migration bundles (EF Core 6+) package your migrations into a standalone executable:
dotnet ef migrations bundle --self-contained -o efbundle
Deploy this alongside your application and run it as part of your deployment pipeline:
./efbundle --connection "Server=prod;Database=App;..."
This is safer than calling Database.Migrate() at startup — it separates schema changes from application startup and gives you explicit control over when migrations run.
Never Call Database.Migrate() in Production
It's tempting to add this to Program.cs:
// DON'T do this in production
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.MigrateAsync();
This causes problems:
- Multiple application instances race to apply migrations simultaneously
- Failed migrations can leave the database in a partially migrated state
- You lose control over when schema changes happen
- Startup time increases
Use it in development and integration tests only. For production, use scripts or bundles applied through your CI/CD pipeline.
Handle Data Migrations Carefully
When you need to transform data as part of a schema change, use migrationBuilder.Sql():
protected override void Up(MigrationBuilder migrationBuilder)
{
// Add new column
migrationBuilder.AddColumn<string>(
name: "FullName",
table: "Users",
nullable: true);
// Migrate data
migrationBuilder.Sql(
"UPDATE Users SET FullName = FirstName + ' ' + LastName");
// Now make it non-nullable
migrationBuilder.AlterColumn<string>(
name: "FullName",
table: "Users",
nullable: false,
defaultValue: "");
// Remove old columns
migrationBuilder.DropColumn(name: "FirstName", table: "Users");
migrationBuilder.DropColumn(name: "LastName", table: "Users");
}
The key technique: add the new column as nullable, populate it, then alter it to non-nullable. Trying to add a non-nullable column to a table with existing data will fail without a default value.
Keep Migrations Small and Focused
One logical change per migration. Don't bundle unrelated schema changes together:
# Good — descriptive, focused names
dotnet ef migrations add AddProductCategoryIndex
dotnet ef migrations add RenameUserEmailToEmailAddress
# Bad — vague, mixed concerns
dotnet ef migrations add UpdateSchema
dotnet ef migrations add Sprint42Changes
Small migrations are easier to review, easier to revert, and cause fewer merge conflicts.
Handling Merge Conflicts
When two developers create migrations against the same model snapshot, you get a merge conflict in the snapshot file. The fix:
# Remove the conflicting migration
dotnet ef migrations remove
# Rebuild from the merged model
dotnet ef migrations add YourMigrationName
Alternatively, if the migrations don't conflict logically, you can regenerate the snapshot:
# After resolving conflicts in .Designer.cs files
dotnet ef migrations script --idempotent
Use Custom Migration Operations
For complex operations, create custom MigrationOperation classes or simply use raw SQL:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Create an index concurrently (PostgreSQL)
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY IX_Products_Name ON \"Products\" (\"Name\")");
}
Squashing Migrations
Over time, you accumulate dozens of migrations. Periodically squash them into a single baseline:
# 1. Ensure all environments are up to date
# 2. Remove all migrations
dotnet ef migrations remove # Repeat until all removed
# 3. Create a single baseline
dotnet ef migrations add InitialBaseline
For existing databases, mark the baseline as already applied:
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
VALUES ('20250923_InitialBaseline', '8.0.0');
Separate Migration Project
Keep migrations in a dedicated project to avoid circular dependencies:
dotnet ef migrations add Init \
--project Infrastructure \
--startup-project WebApi
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly("Infrastructure")));
Testing Migrations
Test that migrations can apply cleanly against an empty database and that rollbacks work:
[Fact]
public async Task Migrations_ApplyCleanly()
{
await using var container = new MsSqlBuilder().Build();
await container.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(container.GetConnectionString())
.Options;
await using var context = new AppDbContext(options);
await context.Database.MigrateAsync(); // Should not throw
}
Index and Constraint Naming
Give your indexes and constraints explicit names:
modelBuilder.Entity<Product>()
.HasIndex(p => p.Name)
.HasDatabaseName("IX_Products_Name");
modelBuilder.Entity<Product>()
.HasIndex(p => new { p.Category, p.Price })
.HasDatabaseName("IX_Products_Category_Price");
This makes migrations more predictable and generated SQL more readable.
Migrations are code — treat them with the same rigour as your application code. Review them, test them, name them well, and deploy them deliberately.