//JorgenHoc
← All articles
EF Core8 min read

EF Core Migrations: The Definitive Walkthrough

A complete walkthrough of EF Core migrations: adding, understanding, applying, rolling back, squashing, and safely deploying migrations to production databases.

#entity-framework#dotnet#database

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.Design

Verify it works:

dotnet ef --version
# Entity Framework Core .NET Command-line Tools 8.x.x

Adding Your First Migration

After setting up your DbContext and entities, create the initial migration:

dotnet ef migrations add InitialCreate

EF 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 InitialCreate

EF 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.0

Adding 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 update

The 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 0
⚠️

Rolling 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 remove

This 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 update

Method 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 entry

A 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.sql

Generating 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.sql

The --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;
GO

Production 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.sql

Migration 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));
}