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
- EF scans your
DbContextmodel. - It diffs it against the last snapshot stored in your project.
- It emits a C# class (the migration) with two methods:
Up()– apply changesDown()– revert changes
- When you run
database update, EF writes an entry into__EFMigrationsHistoryso 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-MigrationTip: install tools locally in the solution folder so teammates run the same version:
dotnet tool restore(paired with adotnet-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.DesignWire 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 InitThis 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 updateIn 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.sqlThis 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.exeThen runmigrate.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:
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.- Targeted downgrade
Find a safe anchor migration and run:dotnet ef database update 20250826102034_InitCaveat: data loss if a later migration dropped/altered columns. Plan for compensating scripts. - 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 NULLtransitions 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/RenameTableor Fluent APIHasColumnNameduring 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
__EFMigrationsHistorystate before running.
FAQ: EF Core Migrations in practice
Batch small changes into one migration per PR. Fewer, meaningful migrations are easier to reason about.
Yes – carefully. I often add data fixes or index hints. Keep edits reviewable and covered by tests.
Generate idempotent scripts and apply per-tenant. For truly isolated schemas, consider a tenant-by-tenant runner that queries history before applying.
Spin up a throwaway DB (LocalDB/SQL Edge/PostgreSQL container), run database update, then assert structure and data with integration tests.
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.
