Home C# / .NET EF Core Migrations Deep-Dive: Production-Ready Database Evolution
Advanced 4 min · July 13, 2026

EF Core Migrations Deep-Dive: Production-Ready Database Evolution

Master EF Core Migrations for production databases.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Production
production tested
July 13, 2026
last updated
2,043
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of C# and .NET
  • Familiarity with Entity Framework Core basics (DbContext, DbSet)
  • SQL Server or another database provider installed for testing
  • dotnet-ef CLI tool installed (dotnet tool install --global dotnet-ef)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • EF Core Migrations manage schema changes over time, enabling incremental updates without data loss.
  • Use dotnet ef migrations add to scaffold changes, Update-Database to apply them.
  • Advanced scenarios include idempotent scripts, custom SQL, and seeding data.
  • Production incidents often involve missing migrations, conflicting changes, or failed rollbacks.
  • Best practices: version control migrations, test in staging, use explicit transactions.
✦ Definition~90s read
What is EF Core Migrations Deep-Dive?

EF Core Migrations are a tool that helps you evolve your database schema over time by generating and applying incremental changes, ensuring your database stays in sync with your application model.

Think of your database schema like a house blueprint.
Plain-English First

Think of your database schema like a house blueprint. EF Core Migrations are like a contractor who keeps a log of every change made to the blueprint—adding a room, moving a wall, updating wiring. When you need to build a new house (deploy to production), the contractor replays all those changes in order, ensuring the new house matches the latest blueprint exactly. If something goes wrong, you can roll back to a previous version.

Imagine you're building a SaaS platform that handles millions of transactions daily. Your database schema evolves constantly—new features, performance optimizations, compliance requirements. Without a robust migration strategy, you risk data loss, downtime, and inconsistent states across environments. EF Core Migrations provide a structured way to evolve your database schema alongside your application code, ensuring that every deployment is predictable and reversible.

In this deep-dive, we'll go beyond basic Add-Migration and Update-Database. You'll learn how to handle complex scenarios like splitting tables, seeding data, generating idempotent scripts for production, and debugging migration failures. We'll also explore a real-world production incident that caused a 2-hour outage due to a poorly handled migration rollback. By the end, you'll have the confidence to manage database changes in high-stakes environments.

Whether you're working on a monolithic app or a microservices architecture, these techniques will help you maintain data integrity and zero-downtime deployments.

Understanding the Migration Pipeline

EF Core Migrations work by comparing your DbContext model with the current database schema. When you add a migration, EF Core generates a file containing Up and Down methods that describe how to transition between schema versions. The __EFMigrationsHistory table tracks which migrations have been applied.

Let's start with a simple example. Suppose you have a Blog entity and you want to add a Rating column. First, add the property to your model:

``csharp public class Blog { public int Id { get; set; } public string Name { get; set; } public int Rating { get; set; } // New property } ``

Then run: `` dotnet ef migrations add AddBlogRating ``

This generates a migration file with Up and Down methods. The Up method adds the column, and the Down method drops it. To apply, run: `` dotnet ef database update ``

But in production, you rarely run Update-Database directly. Instead, you generate SQL scripts that are reviewed and executed by DBAs. Use: `` dotnet ef migrations script --output script.sql ``

This script is idempotent if you use the --idempotent flag, meaning it can be run multiple times without error.

Migrations/20231001000000_AddBlogRating.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public partial class AddBlogRating : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<int>(
            name: "Rating",
            table: "Blogs",
            nullable: false,
            defaultValue: 0);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "Rating",
            table: "Blogs");
    }
}
💡Always Generate Scripts for Production
📊 Production Insight
In high-availability environments, consider using 'online' migrations (e.g., adding columns as nullable first, then backfilling data) to avoid long locks.
🎯 Key Takeaway
Migrations are code-generated schema changes. Always script them for production and test in staging.

Customizing Migrations with Raw SQL

Sometimes the fluent API isn't enough. You may need to execute raw SQL for complex operations like updating existing data, creating indexes with specific options, or using database-specific features. EF Core allows you to embed SQL in migrations using the Sql() method.

For example, suppose you want to add a computed column that concatenates first and last names:

``csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(@" ALTER TABLE ""Users"" ADD ""FullName"" AS (""FirstName"" || ' ' || ""LastName"") "); } ``

Or you might need to backfill data after adding a non-nullable column:

```csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn<string>( name: "TenantId", table: "Blogs", nullable: false, defaultValue: "");

// Backfill existing rows with a default tenant migrationBuilder.Sql(@" UPDATE ""Blogs"" SET ""TenantId"" = 'default' WHERE ""TenantId"" = '' "); } ```

Be careful with SQL injection. Use parameterized SQL if you must include user input. Also, ensure your SQL is compatible with all target databases (e.g., SQL Server vs PostgreSQL).

Migrations/20231002000000_AddComputedColumn.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public partial class AddComputedColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Add computed column
        migrationBuilder.Sql(@"
            ALTER TABLE ""Users"" ADD ""FullName"" AS (""FirstName"" || ' ' || ""LastName"")
        ");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(@"
            ALTER TABLE ""Users"" DROP COLUMN ""FullName""
        ");
    }
}
⚠ SQL in Migrations Can Be Database-Specific
📊 Production Insight
Always test raw SQL on a staging database that mirrors production data volume to catch performance issues.
🎯 Key Takeaway
Use raw SQL for operations not supported by the fluent API, but keep it portable or provider-specific.

Seeding Data with Migrations

Seeding initial data is a common requirement. EF Core provides HasData() in the OnModelCreating method, but this only works for seed data that is part of the initial migration. For ongoing data seeding, you can use migration SQL or custom seeding logic.

Using HasData() is simple but has limitations: it only supports simple types and cannot reference other entities easily. For complex seeds, use Sql() in migrations.

``csharp protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.InsertData( table: "Roles", columns: new[] { "Id", "Name", "NormalizedName" }, values: new object[,] { { Guid.NewGuid().ToString(), "Admin", "ADMIN" }, { Guid.NewGuid().ToString(), "User", "USER" } }); } ``

For production, consider using a separate seed service that runs after migrations, especially for large datasets. This avoids bloating migration files and allows for more complex logic.

Migrations/20231003000000_SeedRoles.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public partial class SeedRoles : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.InsertData(
            table: "Roles",
            columns: new[] { "Id", "Name", "NormalizedName" },
            values: new object[,]
            {
                { "1", "Admin", "ADMIN" },
                { "2", "User", "USER" }
            });
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DeleteData(
            table: "Roles",
            keyColumn: "Id",
            keyValues: new object[] { "1", "2" });
    }
}
🔥Avoid Large Seed Data in Migrations
📊 Production Insight
In production, ensure seed data is idempotent. Use Sql() with IF NOT EXISTS checks to avoid duplicate inserts.
🎯 Key Takeaway
Use InsertData for small seed data in migrations; for large datasets, use a dedicated seeding service.

Handling Complex Schema Changes: Splitting Tables

Sometimes you need to split one table into two, or merge tables. This requires careful migration planning to avoid data loss. Let's walk through splitting a Customer table into Customer and CustomerAddress.

Assume the original Customer table has Id, Name, Street, City. We want to move address fields to a new CustomerAddress table.

Steps: 1. Create the new table with a foreign key to Customer. 2. Copy data from Customer to CustomerAddress. 3. Drop the address columns from Customer.

```csharp protected override void Up(MigrationBuilder migrationBuilder) { // Step 1: Create new table migrationBuilder.CreateTable( name: "CustomerAddresses", columns: table => new { Id = table.Column<int>(nullable: false) .Annotation("SqlServer:Identity", "1, 1"), CustomerId = table.Column<int>(nullable: false), Street = table.Column<string>(nullable: true), City = table.Column<string>(nullable: true) }, constraints: table => { table.PrimaryKey("PK_CustomerAddresses", x => x.Id); table.ForeignKey( name: "FK_CustomerAddresses_Customers_CustomerId", column: x => x.CustomerId, principalTable: "Customers", principalColumn: "Id", onDelete: ReferentialAction.Cascade); });

// Step 2: Copy data migrationBuilder.Sql(@" INSERT INTO ""CustomerAddresses"" (""CustomerId"", ""Street"", ""City"") SELECT ""Id"", ""Street"", ""City"" FROM ""Customers"" ");

// Step 3: Drop old columns migrationBuilder.DropColumn( name: "Street", table: "Customers"); migrationBuilder.DropColumn( name: "City", table: "Customers"); } ```

The Down method would reverse these steps: add columns back, copy data, drop the new table.

Migrations/20231004000000_SplitCustomerTable.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public partial class SplitCustomerTable : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Step 1: Create CustomerAddresses table
        migrationBuilder.CreateTable(
            name: "CustomerAddresses",
            columns: table => new
            {
                Id = table.Column<int>(nullable: false)
                    .Annotation("SqlServer:Identity", "1, 1"),
                CustomerId = table.Column<int>(nullable: false),
                Street = table.Column<string>(nullable: true),
                City = table.Column<string>(nullable: true)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_CustomerAddresses", x => x.Id);
                table.ForeignKey(
                    name: "FK_CustomerAddresses_Customers_CustomerId",
                    column: x => x.CustomerId,
                    principalTable: "Customers",
                    principalColumn: "Id",
                    onDelete: ReferentialAction.Cascade);
            });

        // Step 2: Copy data
        migrationBuilder.Sql(@"
            INSERT INTO ""CustomerAddresses"" (""CustomerId"", ""Street"", ""City"")
            SELECT ""Id"", ""Street"", ""City"" FROM ""Customers""
        ");

        // Step 3: Drop old columns
        migrationBuilder.DropColumn(name: "Street", table: "Customers");
        migrationBuilder.DropColumn(name: "City", table: "Customers");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        // Reverse: add columns, copy data, drop new table
        migrationBuilder.AddColumn<string>(
            name: "Street",
            table: "Customers",
            nullable: true);
        migrationBuilder.AddColumn<string>(
            name: "City",
            table: "Customers",
            nullable: true);

        migrationBuilder.Sql(@"
            UPDATE ""Customers""
            SET ""Street"" = a.""Street"", ""City"" = a.""City""
            FROM ""Customers"" c
            INNER JOIN ""CustomerAddresses"" a ON c.""Id"" = a.""CustomerId""
        ");

        migrationBuilder.DropTable(name: "CustomerAddresses");
    }
}
⚠ Data Copying in Migrations Can Be Slow
📊 Production Insight
In production, consider using a 'shadow' table approach: create the new table alongside the old one, dual-write, then migrate data gradually to avoid downtime.
🎯 Key Takeaway
Complex schema changes like table splitting require multiple steps: create new structure, copy data, drop old columns. Always test on a copy of production data.

Managing Migration History and Versioning

The __EFMigrationsHistory table is crucial for tracking which migrations have been applied. Sometimes you need to manually intervene, e.g., when a migration fails partially or when you need to rebase migration history.

Common scenarios
  • Remove a migration that hasn't been applied: Use dotnet ef migrations remove to undo the last migration.
  • Reset all migrations: Delete the Migrations folder and the database, then create a new initial migration with Add-Migration InitialCreate.
  • Squash migrations: For long-lived projects, you may want to squash many small migrations into one. This involves creating a new initial migration that matches the current schema, then deleting old migration files.

To squash migrations: 1. Ensure the database is up to date with all migrations. 2. Remove the Migrations folder (or rename it). 3. Run dotnet ef migrations add InitialCreate to create a new migration that snapshots the current model. 4. Delete the old migration files from source control. 5. Update __EFMigrationsHistory to reflect the new migration as applied.

This is a delicate operation; always backup the database first.

Commands.txtCSHARP
1
2
3
4
5
6
7
8
# Remove last migration (not applied)
dotnet ef migrations remove

# Add new initial migration after squashing
dotnet ef migrations add InitialCreate

# Generate script for squashed migration
dotnet ef migrations script --output squashed.sql
💡Squash Migrations Only When Necessary
📊 Production Insight
In CI/CD, consider using a tool like dotnet-ef to automatically apply migrations during deployment, but only after generating and reviewing the script.
🎯 Key Takeaway
Manage migration history carefully. Use squashing sparingly and always communicate with the team.

Testing Migrations in CI/CD

Automated testing of migrations is essential to catch issues early. You can write integration tests that apply migrations to a test database and verify the schema and data.

Example using xUnit and a test database (e.g., SQLite in-memory or a local SQL Server):

```csharp public class MigrationTests : IClassFixture<MigrationFixture> { private readonly MigrationFixture _fixture;

public MigrationTests(MigrationFixture fixture) { _fixture = fixture; }

[Fact] public void All_Migrations_Can_Apply_Successfully() { // Arrange var context = _fixture.CreateContext();

// Act context.Database.Migrate();

// Assert var appliedMigrations = context.Database.GetAppliedMigrations(); Assert.Contains("20231001000000_AddBlogRating", appliedMigrations); } }

public class MigrationFixture : IDisposable { private readonly string _connectionString = "Server=(localdb)\\mssqllocaldb;Database=TestMigrations_" + Guid.NewGuid();

public TestDbContext CreateContext() { var options = new DbContextOptionsBuilder<TestDbContext>() .UseSqlServer(_connectionString) .Options; return new TestDbContext(options); }

public void Dispose() { using var context = CreateContext(); context.Database.EnsureDeleted(); } } ```

You can also test rollbacks by applying a migration, then rolling back and verifying the schema reverts.

MigrationTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using Microsoft.EntityFrameworkCore;
using Xunit;

public class MigrationTests : IClassFixture<MigrationFixture>
{
    private readonly MigrationFixture _fixture;

    public MigrationTests(MigrationFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public void All_Migrations_Can_Apply_Successfully()
    {
        using var context = _fixture.CreateContext();
        context.Database.Migrate();
        var applied = context.Database.GetAppliedMigrations();
        Assert.Contains("20231001000000_AddBlogRating", applied);
    }

    [Fact]
    public void Rollback_Works_Correctly()
    {
        using var context = _fixture.CreateContext();
        context.Database.Migrate();
        // Rollback to initial
        context.Database.Migrate("0");
        var applied = context.Database.GetAppliedMigrations();
        Assert.Empty(applied);
    }
}

public class MigrationFixture : IDisposable
{
    private readonly string _connectionString = $"Server=(localdb)\\mssqllocaldb;Database=TestMigrations_{Guid.NewGuid()}";

    public TestDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<TestDbContext>()
            .UseSqlServer(_connectionString)
            .Options;
        return new TestDbContext(options);
    }

    public void Dispose()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
    }
}
🔥Use a Clean Database for Each Test
📊 Production Insight
In production, consider running migration tests against a staging database that mirrors production schema and data volume.
🎯 Key Takeaway
Automate migration testing in CI/CD to catch issues early. Test both apply and rollback.
● Production incidentPOST-MORTEMseverity: high

The Rollback That Wiped a Production Table

Symptom
After rolling back a migration to fix a bug, all customer preferences were missing. Users saw empty settings pages.
Assumption
The developer assumed that rolling back a migration would only revert schema changes, not delete data.
Root cause
The Down method of the migration contained DropColumn for a column that held the only copy of preference data. The rollback dropped the column and data was lost.
Fix
Restored the database from the latest backup. Modified the Down method to first copy data to a temporary table before dropping the column. Added a check in the Up method to preserve existing data.
Key lesson
  • Always review the Down method for data loss potential before rolling back.
  • Use backups as safety net; test rollbacks in staging first.
  • Consider using EnsureCreated or custom SQL to preserve data during schema changes.
  • Implement a 'soft rollback' strategy: deploy a new migration that reverses logic instead of rolling back.
  • Automate migration testing in CI/CD pipelines.
Production debug guideSymptom to Action4 entries
Symptom · 01
dotnet ef database update fails with 'There is already an object named...'
Fix
Check if migration was partially applied. Use Script-Migration -Idempotent to see pending SQL. Manually remove the conflicting object or mark migration as applied using __EFMigrationsHistory.
Symptom · 02
Migration succeeds but data is missing or corrupted
Fix
Review the Up method for unintended data transformations. Use Sql() with explicit transaction. Restore from backup and re-run migration with corrected logic.
Symptom · 03
dotnet ef migrations list shows no migrations but database has tables
Fix
The database might have been created without migrations (e.g., EnsureCreated). Add an initial migration with Add-Migration InitialCreate -IgnoreChanges to snapshot current schema.
Symptom · 04
Migration timeout in production
Fix
Break large migrations into smaller batches. Use Sql() with GO batch separators. Increase command timeout in DbContext configuration.
★ Quick Debug Cheat SheetCommon migration issues and immediate actions
Migration pending but not applied
Immediate action
Run `dotnet ef database update`
Commands
dotnet ef migrations list
dotnet ef database update
Fix now
Apply pending migrations
Migration fails with foreign key conflict+
Immediate action
Check order of operations in `Up` method
Commands
Script-Migration -Idempotent
dotnet ef migrations script --from <previous> --to <current>
Fix now
Reorder or use temporary nullable columns
Need to rollback safely+
Immediate action
Generate script for rollback and review
Commands
dotnet ef migrations script --from <current> --to <previous>
dotnet ef database update <previous>
Fix now
Backup first, then rollback
Migration history out of sync+
Immediate action
Manually update `__EFMigrationsHistory`
Commands
SELECT * FROM __EFMigrationsHistory
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('...', '...')
Fix now
Insert missing row
FeatureMigrationsEnsureCreated
Version controlYesNo
Incremental updatesYesNo
Rollback supportYesNo
Production readyYesNo
Data loss riskLow (if reviewed)High (drops database)
PerformanceCan be slow for large changesFast for initial creation
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
Migrations20231001000000_AddBlogRating.cspublic partial class AddBlogRating : MigrationUnderstanding the Migration Pipeline
Migrations20231002000000_AddComputedColumn.cspublic partial class AddComputedColumn : MigrationCustomizing Migrations with Raw SQL
Migrations20231003000000_SeedRoles.cspublic partial class SeedRoles : MigrationSeeding Data with Migrations
Migrations20231004000000_SplitCustomerTable.cspublic partial class SplitCustomerTable : MigrationHandling Complex Schema Changes
Commands.txtdotnet ef migrations removeManaging Migration History and Versioning
MigrationTests.csusing Microsoft.EntityFrameworkCore;Testing Migrations in CI/CD

Key takeaways

1
Always generate and review SQL scripts before applying migrations to production.
2
Test migrations in a staging environment that mirrors production data volume.
3
Use raw SQL for complex operations, but ensure portability or provider-specific handling.
4
Automate migration testing in CI/CD to catch issues early.
5
Have a rollback plan and always backup the database before applying migrations.

Common mistakes to avoid

4 patterns
×

Running `Update-Database` directly in production

×

Not reviewing the `Down` method before rolling back

×

Using `EnsureCreated` in production

×

Ignoring migration conflicts in team environments

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain the purpose of the `__EFMigrationsHistory` table.
Q02SENIOR
How would you handle a migration that fails in production due to a timeo...
Q03SENIOR
Describe a strategy to migrate a large table without downtime.
Q01 of 03JUNIOR

Explain the purpose of the `__EFMigrationsHistory` table.

ANSWER
It tracks which migrations have been applied to the database. Each row contains the migration ID and product version. EF Core uses it to determine which migrations need to be applied or rolled back.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between `EnsureCreated` and migrations?
02
How do I revert a migration that has been applied to production?
03
Can I use migrations with multiple database providers?
04
How do I handle migrations in a team environment?
05
What is an idempotent migration script?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Verified
production tested
July 13, 2026
last updated
2,043
articles · all by Naren
🔥

That's ASP.NET. Mark it forged?

4 min read · try the examples if you haven't

Previous
Rate Limiting in ASP.NET Core
15 / 35 · ASP.NET
Next
EF Core Relationships and Fluent API