AI WisdomArchitecture & guides β†—
HT
How Things Work

EF Core Migrations & Schema Evolution

How EF Core snapshots your model and generates SQL diff scripts β€” and why running migrations in production needs a strategy.

How It Works

EF Core Migrations are a version control system for your database schema. When you change your entity model, EF diffs it against a stored ModelSnapshot.cs and generates a migration class with Up() and Down() methods containing the schema operations. Applied migrations are tracked in the __EFMigrationsHistory table. This system lets you evolve your schema incrementally β€” but production deployments require careful strategy to avoid table locks, data loss, and race conditions.

1
dotnet ef migrations add Captures a Diff

The CLI compares the current DbContext model (via model snapshot) against the last snapshot stored in Migrations/[timestamp]_Previous.cs. It generates a new migration file with Up() and Down() methods containing the exact schema changes, and updates the ModelSnapshot.cs file.

2
ModelSnapshot Is the Source of Truth

The ModelSnapshot.cs file in your Migrations folder represents what EF Core thinks your database schema currently looks like. It's a C# representation of the full schema, not SQL. Every new migration diffs against it. Losing or corrupting the snapshot breaks future migration generation.

3
Up() and Down() Methods

Up() applies the migration β€” adding columns, creating indexes, etc. Down() reverses it β€” useful for rollbacks. EF Core 9 generates both automatically. Production rollback via Down() is risky for data-loss operations β€” always validate before relying on it.

4
__EFMigrationsHistory Table Tracks Applied Migrations

Every time a migration is applied, EF inserts a row into __EFMigrationsHistory with the migration ID and EF Core version. When MigrateAsync() runs, EF checks this table and only applies migrations whose IDs are not present. This makes migrations idempotent across environments.

5
Production Deployment Strategy

Recommended flow: 1) Generate idempotent SQL script (dotnet ef migrations script --idempotent), 2) Review the SQL, 3) Run it against production as a pre-deployment step, 4) Deploy the new app version. Never call MigrateAsync() in app startup when running multiple instances β€” two instances racing to apply migrations causes 'Migration already exists' errors or duplicate operations.

Key Concepts

πŸ“ΈModelSnapshot

A C# file auto-maintained by EF that records the complete current model. Diff target for new migrations. Never edit manually. Commit it to git.

⬆️Up() / Down()

Migration methods for forward (apply) and reverse (rollback) schema changes. Down() is auto-generated but should be manually verified for destructive operations.

πŸ“‹__EFMigrationsHistory

A table EF creates in your database to track which migrations have been applied. Used by MigrateAsync() to determine pending migrations.

πŸ“¦Migration Bundle

EF Core 6+ feature: 'dotnet ef migrations bundle' creates a self-contained executable that applies migrations. No dotnet SDK needed in production containers.

πŸ”Idempotent Script

Generated with --idempotent flag. Each statement checks if it's already been applied before executing. Safe to re-run β€” useful in CI/CD pipelines.

🌱Seed Data

modelBuilder.Entity<T>().HasData() inserts reference data during migrations. EF uses primary key to detect insert vs update, making it idempotent.

Migration patterns β€” safe column additions and deployment
tsx
1// EF Core 9 β€” generated migration (DO NOT hand-edit the snapshot)
2public partial class AddOrderShippedAtAndIndex : Migration
3{
4 protected override void Up(MigrationBuilder migrationBuilder)
5 {
6 // Add nullable column first β€” safe for existing rows
7 migrationBuilder.AddColumn<DateTime>(
8 name: "ShippedAt",
9 table: "Orders",
10 type: "datetime2",
11 nullable: true); // <-- NEVER non-nullable without a default
12
13 // Add index in a separate step β€” can be done ONLINE in SQL Server
14 migrationBuilder.CreateIndex(
15 name: "IX_Orders_ShippedAt",
16 table: "Orders",
17 column: "ShippedAt",
18 filter: "[ShippedAt] IS NOT NULL"); // partial index β€” sparse
19 }
20
21 protected override void Down(MigrationBuilder migrationBuilder)
22 {
23 migrationBuilder.DropIndex(name: "IX_Orders_ShippedAt", table: "Orders");
24 migrationBuilder.DropColumn(name: "ShippedAt", table: "Orders");
25 }
26}
27
28// EF Core 9: migration bundles (single executable β€” no dotnet-ef needed in prod)
29// dotnet ef migrations bundle --output ./efbundle
30
31// Apply programmatically β€” BUT NOT on app startup in production
32await using var scope = app.Services.CreateAsyncScope();
33var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
34await db.Database.MigrateAsync(); // Applies all pending migrations
35
36// Idempotent SQL script for zero-downtime deployments
37// dotnet ef migrations script --idempotent --output migrations.sql
38// Each statement wrapped in IF NOT EXISTS checks
πŸ’‘
Why This Matters

Migrations are the safest way to evolve a production database schema while keeping your C# model and database in sync. But they are sharp tools: a generated migration can silently drop a column that was renamed, add a NOT NULL column that locks a 10M-row table, or race with other app instances to apply the same change twice. Understanding how migrations work β€” and how they fail β€” is essential for zero-downtime deployments.

Common Pitfalls

⚠Running Database.MigrateAsync() in app startup with multiple instances causes a race condition. Two instances can simultaneously see migration X as pending, both apply it, and both try to insert into __EFMigrationsHistory β€” one will crash with a unique constraint violation.
⚠Renaming a property generates DropColumn + AddColumn β€” not RenameColumn. EF Core cannot infer intent from a snapshot diff. Always review generated migration SQL and manually change to RenameColumn when appropriate.
⚠Adding a non-nullable column to a large table causes a full table lock while SQL Server backfills default values. Always add as nullable first, backfill in batches, then alter to NOT NULL in a separate release.
⚠Keeping migrations in a separate project from the DbContext causes the ModelSnapshot to lose its reference. You get 'Unable to create an object of type AppDbContext' and the scaffold fails. Use --project and --startup-project flags, or keep them together.
Real-World Use Cases

1Race Condition: Two App Instances Applying Migrations on Startup

Scenario

After deploying a new version during peak traffic, we rolled out with 3 instances. The deployment window showed 2 instances failing to start. The error logs read: 'Cannot insert duplicate key row in object __EFMigrationsHistory with unique index.'

Problem

All three instances called 'await db.Database.MigrateAsync()' in Program.cs at startup. Instance A read the history table, saw migration X as pending, and started applying it. Instances B and C simultaneously did the same β€” all three tried to insert the migration record into __EFMigrationsHistory, and two of them lost the race with a unique constraint violation.

Solution

Remove MigrateAsync() from application startup entirely. Use a pre-deployment migration step: run 'dotnet ef migrations bundle' in CI to create an efbundle executable, then run it as part of the deployment pipeline before the new app instances start. Alternatively, use a distributed lock (IDistributedLock) around the migration call if you must do it in-process.

πŸ’‘

Takeaway: Migrations are a deployment concern, not an application startup concern. Never run MigrateAsync() when you might have multiple app instances starting simultaneously. The __EFMigrationsHistory unique constraint will ensure only one succeeds β€” and the others will crash.

2Destructive Column Rename Silently Drops Data

Scenario

A developer renamed a property from 'Address' to 'ShippingAddress' in the Order entity, ran 'dotnet ef migrations add RenameColumn', and deployed to staging. The migration ran successfully. Then they noticed all existing orders had a null ShippingAddress β€” 50,000 rows of address data were gone.

Problem

EF Core cannot infer that 'Address' was renamed to 'ShippingAddress'. It sees a column was removed and a new column was added. The generated migration contained 'DropColumn("Address")' followed by 'AddColumn("ShippingAddress")'. All data in the original column was deleted.

Solution

For column renames, manually edit the migration to use 'migrationBuilder.RenameColumn("Address", "Orders", "ShippingAddress")' instead of the generated drop+add. Always review generated migrations before applying them. Add a CI check that diffs the generated SQL against approved patterns to catch unexpected DropColumn operations.

πŸ’‘

Takeaway: EF Core generates migrations based on model snapshots, not intent. It cannot distinguish a rename from a drop+add. Always review generated migration SQL β€” especially in staging β€” before running against production data.

3Adding a Non-Nullable Column to a 10M-Row Table Locks the Table

Scenario

A developer added a required (non-nullable) string property 'Region' with no default value. The migration ran fine in dev (200 rows) and staging (5,000 rows). In production (10 million rows), the migration ran for 22 minutes and caused a complete outage because SQL Server locked the table while backfilling.

Problem

SQL Server must provide a value for all existing rows when adding a non-nullable column without a default. It locks the entire table, reads every row, writes the default value, then updates statistics. On 10M rows this is a full table rewrite. The table was locked for writes the entire time.

Solution

Three-phase migration: Phase 1 β€” add the column as nullable with no default (instant, no lock). Phase 2 β€” backfill the data in batches: 'UPDATE Orders SET Region = 'US' WHERE Region IS NULL AND Id BETWEEN @min AND @max' in loops of 10,000 rows. Phase 3 β€” alter column to NOT NULL after all rows are filled (fast metadata change). Deploy app changes that handle null during the transition window.

πŸ’‘

Takeaway: Never add a non-nullable column without a default to a large production table in a single migration. Always make it nullable first, backfill data, then enforce the constraint. What takes 5ms in dev can take 20 minutes in production.