โ† .NET Concepts

EF Core Migrations Workflow

Published on 2026-10-04ยทv1.0

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 Up and Down you can read in a pull request.
  • Renaming a property without losing the data in its column.
  • Backfilling a new NOT NULL column 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

plaintext
dotnet 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.

plaintext
public 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:

plaintext
dotnet 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:

plaintext
migrationBuilder.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.
  • Down is rarely exercised. A Down that 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.
  • HasData is 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.

Documentation Links