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.
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.
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.
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.
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.
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.
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
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.
Migration methods for forward (apply) and reverse (rollback) schema changes. Down() is auto-generated but should be manually verified for destructive operations.
A table EF creates in your database to track which migrations have been applied. Used by MigrateAsync() to determine pending migrations.
EF Core 6+ feature: 'dotnet ef migrations bundle' creates a self-contained executable that applies migrations. No dotnet SDK needed in production containers.
Generated with --idempotent flag. Each statement checks if it's already been applied before executing. Safe to re-run β useful in CI/CD pipelines.
modelBuilder.Entity<T>().HasData() inserts reference data during migrations. EF uses primary key to detect insert vs update, making it idempotent.
1// EF Core 9 β generated migration (DO NOT hand-edit the snapshot)2public partial class AddOrderShippedAtAndIndex : Migration3{4 protected override void Up(MigrationBuilder migrationBuilder)5 {6 // Add nullable column first β safe for existing rows7 migrationBuilder.AddColumn<DateTime>(8 name: "ShippedAt",9 table: "Orders",10 type: "datetime2",11 nullable: true); // <-- NEVER non-nullable without a default1213 // Add index in a separate step β can be done ONLINE in SQL Server14 migrationBuilder.CreateIndex(15 name: "IX_Orders_ShippedAt",16 table: "Orders",17 column: "ShippedAt",18 filter: "[ShippedAt] IS NOT NULL"); // partial index β sparse19 }2021 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}2728// EF Core 9: migration bundles (single executable β no dotnet-ef needed in prod)29// dotnet ef migrations bundle --output ./efbundle3031// Apply programmatically β BUT NOT on app startup in production32await using var scope = app.Services.CreateAsyncScope();33var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();34await db.Database.MigrateAsync(); // Applies all pending migrations3536// Idempotent SQL script for zero-downtime deployments37// dotnet ef migrations script --idempotent --output migrations.sql38// Each statement wrapped in IF NOT EXISTS checks
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
1Race Condition: Two App Instances Applying Migrations on Startup
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.'
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.
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
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.
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.
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
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.
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.
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.