EF Core Migrations Deep-Dive: Production-Ready Database Evolution
Master EF Core Migrations for production databases.
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
- ✓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)
- EF Core Migrations manage schema changes over time, enabling incremental updates without data loss.
- Use
dotnet ef migrations addto scaffold changes,Update-Databaseto 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.
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.
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).
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.
Example: Seed a list of roles:
``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.
Sql() with IF NOT EXISTS checks to avoid duplicate inserts.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.
In the Up method:
```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.
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.
- Remove a migration that hasn't been applied: Use
dotnet ef migrations removeto 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.
dotnet-ef to automatically apply migrations during deployment, but only after generating and reviewing the script.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.
The Rollback That Wiped a Production Table
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.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.- Always review the
Downmethod for data loss potential before rolling back. - Use backups as safety net; test rollbacks in staging first.
- Consider using
EnsureCreatedor 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.
dotnet ef database update fails with 'There is already an object named...'Script-Migration -Idempotent to see pending SQL. Manually remove the conflicting object or mark migration as applied using __EFMigrationsHistory.Up method for unintended data transformations. Use Sql() with explicit transaction. Restore from backup and re-run migration with corrected logic.dotnet ef migrations list shows no migrations but database has tablesEnsureCreated). Add an initial migration with Add-Migration InitialCreate -IgnoreChanges to snapshot current schema.Sql() with GO batch separators. Increase command timeout in DbContext configuration.dotnet ef migrations listdotnet ef database update| File | Command / Code | Purpose |
|---|---|---|
| Migrations | public partial class AddBlogRating : Migration | Understanding the Migration Pipeline |
| Migrations | public partial class AddComputedColumn : Migration | Customizing Migrations with Raw SQL |
| Migrations | public partial class SeedRoles : Migration | Seeding Data with Migrations |
| Migrations | public partial class SplitCustomerTable : Migration | Handling Complex Schema Changes |
| Commands.txt | dotnet ef migrations remove | Managing Migration History and Versioning |
| MigrationTests.cs | using Microsoft.EntityFrameworkCore; | Testing Migrations in CI/CD |
Key takeaways
Common mistakes to avoid
4 patternsRunning `Update-Database` directly in production
Not reviewing the `Down` method before rolling back
Using `EnsureCreated` in production
Ignoring migration conflicts in team environments
Interview Questions on This Topic
Explain the purpose of the `__EFMigrationsHistory` table.
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
That's ASP.NET. Mark it forged?
4 min read · try the examples if you haven't