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
DbContext
model. - 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__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 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.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 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_Init
Caveat: 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 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 APIHasColumnName
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
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.
- EF Core for .NET Devs: A Pragmatic Introduction
- Install EF Core Fast: Step-by-Step Beginner’s Guide
- EF Core Migrations: Practical, Battle‑Tested Guide (2025)
- Entity Framework Data Annotations & Fluent API Configuration
- EF Core Global Filters: Soft Delete & Multi‑Tenant Guide