EF Core migrations are the version control system for your database schema. Every change to your entity model gets captured as a timestamped, reversible migration file. This walkthrough covers everything from the first migration to production deployment strategies.
Prerequisites
Install the EF Core tools globally and add the design package to your project:
dotnet tool install --global dotnet-ef
dotnet tool update --global dotnet-ef # Update if already installed
# Add to your project
dotnet add package Microsoft.EntityFrameworkCore.DesignVerify it works:
dotnet ef --version
# Entity Framework Core .NET Command-line Tools 8.x.xAdding Your First Migration
After setting up your DbContext and entities, create the initial migration:
dotnet ef migrations add InitialCreateEF Core inspects your DbContext, compares it to the current database state (empty, for the first migration), and generates the migration files.
Three files appear in a Migrations/ folder:
Migrations/
20250120143000_InitialCreate.cs ← The migration itself
20250120143000_InitialCreate.Designer.cs ← Snapshot metadata (don't edit)
AppDbContextModelSnapshot.cs ← Current model snapshot (don't edit)
Understanding the Migration File
// Migrations/20250120143000_InitialCreate.cs
public partial class InitialCreate : Migration
{
/// <summary>
/// Runs when applying the migration (dotnet ef database update)
/// </summary>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Price = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
});
}
/// <summary>
/// Runs when rolling back the migration (dotnet ef database update <previous>)
/// </summary>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Products");
}
}The ModelSnapshot file is critical — EF Core uses it to determine what changed between migrations. Never delete it.
Applying Migrations
# Apply all pending migrations
dotnet ef database update
# Apply up to a specific migration
dotnet ef database update AddProductDescription
# Apply the initial migration only
dotnet ef database update InitialCreateEF Core maintains an __EFMigrationsHistory table in your database that tracks which migrations have been applied:
SELECT * FROM __EFMigrationsHistory;
-- MigrationId | ProductVersion
-- 20250120143000_InitialCreate | 8.0.0
-- 20250124091500_AddProductDescription | 8.0.0Adding Subsequent Migrations
After changing an entity, add a new migration:
// Add a Description property to Product
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
public string? Description { get; set; } // New property
public DateTime CreatedAt { get; set; }
}dotnet ef migrations add AddProductDescription
dotnet ef database updateThe generated migration only contains the delta:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "Products",
type: "nvarchar(max)",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "Products");
}Rolling Back Migrations
Roll Back to a Specific Migration
# Roll back to after InitialCreate (undoes AddProductDescription)
dotnet ef database update InitialCreate
# Roll back all migrations (empty database — tables dropped but __EFMigrationsHistory remains)
dotnet ef database update 0Rolling back drops data. dotnet ef database update 0 will drop all your tables. Only do this in development or with a confirmed backup.
Remove the Last Migration (Before Applying)
If you haven't applied a migration yet, you can remove it entirely:
dotnet ef migrations removeThis deletes the migration file and reverts the ModelSnapshot. You can only remove the most recent migration, and only if it hasn't been applied to any database.
If you accidentally run dotnet ef database update on a broken migration, you must first roll back the database before removing the migration file.
Customizing Migrations
EF Core's generated migrations are a starting point — you can edit them to add data seeding, computed columns, or other operations EF Core can't auto-detect:
protected override void Up(MigrationBuilder migrationBuilder)
{
// EF Core generated this
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "Products",
nullable: true);
// You added this — populate Slug from existing Name data
migrationBuilder.Sql(@"
UPDATE Products
SET Slug = LOWER(REPLACE(Name, ' ', '-'))
WHERE Slug IS NULL
");
// Then make it non-nullable after populating
migrationBuilder.AlterColumn<string>(
name: "Slug",
table: "Products",
nullable: false,
oldClrType: typeof(string),
oldNullable: true);
}Editing a migration that has already been applied to any database (dev, staging, production) is dangerous. The ModelSnapshot won't match your migration file. Only edit unapplied migrations.
Squashing Migrations
Over time, hundreds of migrations accumulate. Squashing combines them into a single migration for a cleaner history. This is typically done at a major version boundary.
Method 1: Start Fresh (destructive — dev only)
# 1. Delete all migration files
# 2. Drop the database
dotnet ef database drop --force
# 3. Add a single new initial migration
dotnet ef migrations add InitialCreate
# 4. Recreate the database
dotnet ef database updateMethod 2: Consolidate Without Dropping Data (production-safe)
# 1. Note the current migration name
dotnet ef migrations list
# 2. Add a new squash migration that starts from scratch
# (EF Core won't generate anything — the model matches the DB)
dotnet ef migrations add Squash_v2 --no-build
# 3. Manually replace the Up() and Down() of the new migration
# with the full schema creation/destruction SQL
# 4. Update __EFMigrationsHistory in production to remove old entries
# and add only the new squash migration entryA more practical approach using the Script-Migration output:
# Generate SQL script for the full current schema
dotnet ef migrations script 0 AddProductDescription --output schema_v2.sqlGenerating SQL Scripts
For production deployments, generate a SQL script instead of running dotnet ef database update directly:
# Full script from empty to latest
dotnet ef migrations script --output migration.sql
# Script from a specific migration to latest (idempotent)
dotnet ef migrations script InitialCreate --idempotent --output migration.sql
# Script between two specific migrations
dotnet ef migrations script InitialCreate AddProductDescription --output delta.sqlThe --idempotent flag generates a script that checks __EFMigrationsHistory before running each migration — safe to run multiple times:
IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20250124091500_AddProductDescription')
BEGIN
ALTER TABLE [Products] ADD [Description] nvarchar(max) NULL;
END;
GOProduction Deployment Strategy
Option 1: Automated Startup Migration
Apply pending migrations when the app starts:
// Program.cs
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
app.Run();Good for: Small teams, blue/green deployments where only one version runs at a time.
Risky when: Multiple instances start simultaneously (race condition on migrations). Mitigate with a distributed lock or by running migrations as a pre-deploy step.
Option 2: Migration as a Separate Job
Run migrations as a one-off job before deploying the new app version:
# In your CI/CD pipeline, before deploying the app:
dotnet ef database update --connection "$PRODUCTION_CONNECTION_STRING"
# Or using a dedicated migrate job in Docker:
dotnet run --project MyApp.Migrations// MyApp.Migrations/Program.cs (dedicated migration project)
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(context.Configuration.GetConnectionString("DefaultConnection")));
})
.Build();
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Console.WriteLine("Applying migrations...");
await db.Database.MigrateAsync();
Console.WriteLine("Migrations complete.");Option 3: SQL Scripts in CI/CD
The safest approach for regulated environments:
# GitHub Actions example
- name: Generate migration script
run: dotnet ef migrations script --idempotent --output migration.sql
- name: Apply migration script
run: |
sqlcmd -S ${{ secrets.DB_SERVER }} \
-d ${{ secrets.DB_NAME }} \
-U ${{ secrets.DB_USER }} \
-P ${{ secrets.DB_PASS }} \
-i migration.sqlMigration Best Practices
Keep migrations small and focused. A migration that adds a column and backfills data is two operations — consider splitting them for safer rollbacks.
Use descriptive migration names that describe what changed: AddUserEmailIndex, RenameProductCodeToSku, CreateOrdersTable. The timestamp is already unique; the name is for humans.
Never rename or delete migration files that have been applied to production. EF Core uses the filename to match against __EFMigrationsHistory. Renaming causes "migration not found" errors.
Checking Migration Status
# List all migrations and their applied status
dotnet ef migrations list
# Output:
# 20250120143000_InitialCreate (Applied)
# 20250124091500_AddProductDescription (Applied)
# 20250127110000_AddIndexOnProductName (Pending)Check programmatically:
var pendingMigrations = await db.Database.GetPendingMigrationsAsync();
var appliedMigrations = await db.Database.GetAppliedMigrationsAsync();
if (pendingMigrations.Any())
{
_logger.LogWarning("Pending migrations: {Migrations}",
string.Join(", ", pendingMigrations));
}