EF Core Migrations Workflow
Objective
A migration is a C# file that moves the database schema from one version to the next, generated by comparing your model with a snapshot of the previous one. The tooling is easy to run and easy to trust too much. EF Core generates what it can infer from the model difference, and what it infers is sometimes destructive: a rename looks like a drop and an add. A reliable workflow treats the generated migration as code to review, keeps the snapshot consistent across branches, and lets CI prove that the model and the migrations agree.
Use Cases
- Adding a column and getting a migration whose
UpandDownyou can read in a pull request. - Renaming a property without losing the data in its column.
- Backfilling a new
NOT NULLcolumn for existing rows as part of the same change. - Two developers adding migrations on two branches, and merging them without a broken snapshot.
- Failing the build when someone changes an entity and forgets to add a migration.
Deep Dive
What migrations add produces
plaintextdotnet ef migrations add AddOrderPriority --project Orders.Core --startup-project Host
Three files appear. The migration class has Up and Down. A Designer file
holds metadata. OrdersDbContextModelSnapshot.cs is a full description of the
model as of this migration. The next migrations add diffs your current model
against that snapshot, not against the live database, which is why the snapshot
must be committed and must always match the last migration.
plaintextpublic partial class AddOrderPriority : Migration { protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.AddColumn<int>( name: "priority", schema: "orders", table: "orders", type: "integer", nullable: false, defaultValue: 0); protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropColumn(name: "priority", schema: "orders", table: "orders"); }
The tool needs a way to build your context without running the application. If
the app host is not enough, give it an IDesignTimeDbContextFactory<T>.
Review the generated SQL
Read the migration, and read the SQL it will run, before merging:
plaintextdotnet ef migrations script PreviousMigration AddOrderPriority --idempotent
The most important thing to look for is a rename. EF Core compares the old and
new model and cannot know that Total became GrandTotal, so it emits a drop
and an add, and the data in the column is gone:
plaintext// Generated: loses the data. migrationBuilder.DropColumn(name: "total", schema: "orders", table: "orders"); migrationBuilder.AddColumn<decimal>(name: "grand_total", schema: "orders", table: "orders", ...); // What you meant: edit the migration by hand. migrationBuilder.RenameColumn(name: "total", schema: "orders", table: "orders", newName: "grand_total");
Data changes belong in the migration
When a schema change needs existing rows to change too, add the SQL to the same migration so schema and data move together. For a new required column, add it as nullable, fill it, then make it required:
plaintextmigrationBuilder.AddColumn<string>("status_text", "orders", "orders", nullable: true); migrationBuilder.Sql("UPDATE orders.orders SET status_text = CASE status WHEN 0 THEN 'Placed' ELSE 'Closed' END"); migrationBuilder.AlterColumn<string>("status_text", "orders", "orders", nullable: false, oldNullable: true);
Prefer UseSeeding and UseAsyncSeeding (EF Core 9 and later) for seed data over
HasData, because HasData bakes the rows into every future snapshot. Always
implement UseSeeding too: the EF tools and migration bundles call the
synchronous delegate even when your application uses the asynchronous one.
Branches and the snapshot
Two branches that each add a migration both edit the model snapshot, and a migration also carries the model as it was at that point. If both are merged, the later migration's snapshot does not include the other branch's changes, which can corrupt later migrations. EF Core 10 and earlier do not record the latest migration in the snapshot, so source control can merge it without any conflict even though the migration trees diverged. The documented fix is to re-create your migration on top of your teammate's:
plaintext# Before merging, while your branch is still coherent: dotnet ef migrations remove # remove only YOUR migration, keep the model change git merge main # bring in the other branch's migration and snapshot dotnet ef migrations add AddOrderPriority # re-add it on top of the merged snapshot
If the merge already happened, do not run migrations remove: it restores the
model from the previous migration's metadata, which may lack the other branch's
changes. Return to a coherent pre-merge state with source control and follow the
steps above.
Let CI check the model
dotnet ef migrations has-pending-model-changes (EF Core 8 and later) checks
whether the model has changes that no migration captures yet, so a CI step can
catch a forgotten migration. context.Database.HasPendingModelChanges() does the
same check from code, for example in a unit test. Since EF Core 9, Migrate() also throws if there are pending model changes,
which catches the same mistake at startup in a test environment.
Trade-offs
- Auto-generated does not mean correct. Renames, column type changes that need a conversion, and splitting a table all need a hand-edited migration. Reviewing only the C# is not enough for a type change, so read the SQL.
Downis rarely exercised. ADownthat drops a column cannot bring back the data, so for production, a corrective forward migration is usually safer than reverting.- Editing an applied migration breaks other environments. Once a migration has run anywhere shared, changing it in place leaves databases in different states with the same migration id. Add a new migration instead.
- The snapshot is a single point of conflict. Many people adding migrations in a short window means regenerating often. Small, frequent merges reduce it.
HasDatais convenient and heavy. It stores every seed row in the snapshot, so changing one row generates a diff, and large seeds slow tooling. Use it only for small reference data that truly never changes.