EF Core Migrations: Managing Schema Changes Safely

Ship EF Core migrations safely: add and remove migrations, generate SQL scripts and bundles, avoid startup Migrate() races, handle data loss.

Executive Summary: EF Core migrations are versioned, reviewable schema changes: each one is a C# class pair generated from the diff between your model and the last snapshot, applied to databases by script, bundle, or command. The practices that separate safe teams from scared ones are four: review every migration before it ships (the scaffolder will happily generate data-losing statements), apply migrations as a deliberate deployment step rather than a startup side effect, script idempotently for environments you cannot reach from your laptop, and use expand-and-contract for changes that must not take downtime.

The incident that motivates this page usually sounds like this: a service scaled to three instances behind a load balancer, each running context.Database.Migrate() at startup, and the first deployment after that took fifteen minutes of intermittent SqliteException: database is locked errors while three processes raced each other through the same migration. Nothing was lost, but the rollback was done in a hurry, and the fix, applying migrations once as a deployment step, was a lesson paid for on a Friday evening.

EF Core migrations are the schema-evolution half of code-first: commands that diff your model against the previous snapshot, C# files that describe the change, and tools that apply those changes to databases, on your machine or in production. The migrations overview is the official reference. This page is about shipping them without drama, building on the model from the EF Core code-first guide.

The examples assume .NET SDK 10.0 with EF Core 10 and continue the TaskApi, where the next feature, a priority column on tasks, needs to reach a production database without a maintenance window.

After several migrations gone right and a couple gone wrong, I am sure of this: the migration tooling is excellent and the process around it is where teams fail. The defaults are tuned for a developer’s laptop, and production deserves different ones.

What an EF Core migration actually is

Run dotnet ef migrations add AddTaskPriority and EF Core compares your current model against the model snapshot, the C# file it keeps of the last known schema. The output is a new migration file, a class with an Up method (statements to move the database forward) and a Down method (statements to undo it), plus an updated snapshot:

public partial class AddTaskPriority : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<int>(
            name: "Priority",
            table: "Tasks",
            type: "INTEGER",
            nullable: false,
            defaultValue: 0);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "Priority", table: "Tasks");
    }
}

Two consequences follow. First, the snapshot file is what makes the next diff correct, so commit it with the migration. Only the tooling should edit it. Second, migrations are plain C# calling a fluent builder, which means you can and should read every generated file before it ships.

Add, apply, and remove: the daily commands

dotnet ef migrations add AddTaskPriority   # scaffold the change
dotnet ef migrations list                   # what exists, what is applied
dotnet ef database update                   # apply to the dev database
dotnet ef migrations remove                 # undo the LAST migration, if unapplied

Expected output for the apply, trimmed:

Build started...
Build succeeded.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (6ms) ALTER TABLE "Tasks" ADD "Priority" INTEGER NOT NULL DEFAULT 0;
Done.

migrations remove deserves a caution flag: it deletes the newest migration and rolls the snapshot back. But only safely when that migration has not been applied anywhere. The common accident is removing an applied migration, regenerating a different one, and leaving every environment’s migration history out of sync with the files. The migrations documentation covers the recovery paths; the prevention is simple: list before remove.

The rename trap: scaffolds do not read your mind

Change a property name in code, and the scaffolder sees “drop column, add column”, because from the outside a rename and a drop-plus-add look identical:

WRONG (generated, data-losing):

migrationBuilder.DropColumn(name: "Title", table: "Tasks");
migrationBuilder.AddColumn<string>(name: "Name", table: "Tasks", ...);

RIGHT (your edit, after reading the scaffold):

migrationBuilder.RenameColumn(
    name: "Title",
    table: "Tasks",
    newName: "Name");

This is the entire argument for reviewing generated migrations: the scaffolder also prints warnings for anything potentially destructive, like An operation was scaffolded that may result in the loss of data, and those warnings are load-bearing. A rename reviewed in one minute ships safely; the same rename shipped blind drops the column and its data.

SQL scripts: the production shape

Production databases are usually not reachable from a developer’s machine, and that is fine. This is because migrations can be rendered to SQL exactly as they will run:

dotnet ef migrations script --idempotent -o migrations.sql

The --idempotent flag matters in real deployments: the script then checks which migrations are already recorded in the __EFMigrationsHistory table before running each block, so applying it twice (say, a retried deploy) is a no-op instead of an error. For multi-environment chains, --from and --to generate exactly the delta between two states, so staging and production get the same reviewed file. The generated script is reviewed like code, because it is the code that touches production.

Migration bundles: apply without an SDK

Many production environments have no .NET SDK and no EF tools, only the runtime. A bundle is the answer: a single executable that carries the migrations and applies them:

dotnet ef migrations bundle --self-contained -r linux-x64 -o migrate

# in the deployment environment:
./migrate --connection "..."

The bundle is produced in the build, stored as a pipeline artifact, and executed as a deployment step, one application, no SDK, no source code on the server. The applying migrations reference documents scripts, bundles, and the command-line options for both, including the --connection and context arguments.

Why Migrate() at startup breaks with more than one instance

EF Core exposes context.Database.Migrate() for applying pending migrations from code, and tutorials use it because it makes a single instance self-bootstrapping. In a scaled-out deployment it turns every deploy into a race:

// fine for a single-instance dev tool:
app.Services.CreateScope().ServiceProvider
    .GetRequiredService<TaskApiContext>().Database.Migrate();

Three instances starting together means three processes executing the same migration concurrently. The database serializes them with locks (one wins, the others wait or fail), the losing instances may crash their startup and restart in a loop. The migration that was “free” becomes the most expensive part of the deployment. The production pattern is to apply migrations once, before the new instances start, as a pipeline step: a bundle or an idempotent script in the release job, which the guide on CI/CD pipelines for .NET projects places in the deploy stage, before the App Service slots swap in the guide on deploying .NET apps to Azure App Service.

Team merge conflicts: the snapshot problem

Two branches that each add a migration both rewrite the shared model snapshot, so the second merge brings a conflict in the snapshot file. The practical workflow, from the migrations in team environments guide:

  1. Merge the code changes, keeping the snapshot from the incoming branch.
  2. Run dotnet ef migrations remove on the branch’s last unapplied migration, restoring the snapshot to the pre-merge model.
  3. Run dotnet ef migrations add ... again, regenerating the migration against the merged model.

Short-lived branches and small migrations make these conflicts rare and trivial; long-lived feature branches make them routine. The rule teams converge on: migrate early, merge often, and never resolve a snapshot conflict by hand when the tooling can regenerate it correctly.

Expand and contract: changing schema without downtime

A migration and the code that uses it can ship in one deployment only when the change is compatible in both directions. When it is not, the safe sequence is expand and contract, three deployments:

1. EXPAND: add Priority as nullable, old code ignores it
   migrationBuilder.AddColumn<int>(name: "Priority", table: "Tasks",
       nullable: true);

2. BACKFILL + DEPLOY: run a batch update in chunks (SQL or EF),
   then deploy code that writes Priority and requires it

3. CONTRACT (later, after rollback window closes):
   drop the old shape, add the NOT NULL constraint

The principle is that at every instant, both the previous version and the new version of the application can run correctly against the schema. That is what makes blue-green deployments, rolling restarts, and the App Service staging-slot swap safe. The database never breaks a running version of the code.

How real systems do this

  • Migrations apply as a deploy step, once. The pipeline runs the bundle or idempotent script before instances of the new version start. This means the schema is correct before the first request and no instances race.
  • Destructive operations get a second reviewer. Drop column, rename, and type narrowing changes carry the scaffold warnings into the pull request description. Anyone can see what the migration does to existing rows.
  • Big tables get timed migrations. Adding a column to a table with tens of millions of rows takes a lock (long or short depending on the database and default). Teams run such changes off-peak or use built-in online operations, and measure first.

A mistake I have seen in production was a “small rename” that was not reviewed: the scaffolded drop-plus-add for a Title to Name rename shipped straight through. The migration’s data loss warning scrolled past in the console. The column’s data, thousands of user-entered titles, was gone after the next deploy. The incident post-mortem found the warning in the build log, unread. The fix was as organizational as technical: generated migrations became review-required artifacts, with warnings quoted in the pull request. What stuck with me: the scaffolder tells you exactly when it plans to delete data, and ignoring that message turns a rename into a restore-from-backup.

Decision framework: how should this change ship?

  1. Is the change additive and compatible? New nullable column, new table: one migration plus one deploy, expand and contract not needed.
  2. Does old code still work against the new schema? If no, split into expand, backfill, and contract phases so a rolling deploy never runs against the wrong shape.
  3. How is it applied in production? Pipeline step with a bundle or idempotent script; startup Migrate() only for tools that genuinely run one instance.
  4. Does it touch a large table? Measure lock time on a production-sized copy before choosing the window; batch backfills in chunks instead of one transaction.
  5. Is anything destructive in the file? Rename edits, warning quotes in the description, and a named reviewer. If the rollback plan is “restore the backup”, the change is not ready.

When NOT to use EF Core migrations

  • Throwaway databases. Integration tests and local sandboxes rebuild with EnsureCreated() or recreate the file. Migration history is bookkeeping they do not need.
  • Schemas owned by a DBA process. When schema review happens through change tickets and DBA tooling, generate the script for their pipeline instead of applying it yourself, or the migration history and the change process will fight.
  • Massive one-off data transformations. Backfilling millions of rows is a batch job with progress and resumability, not a migration that runs in a deploy step. Migrations change schema; jobs change data.

Common mistakes

  • Startup Migrate() on scaled-out apps. Concurrent instances race the same migration and restart-loop on lock errors. Apply once as a deploy step.
  • Shipping unreviewed scaffolds. The generator’s drop-plus-add and its loss warnings are only suggestions until a human reads them. Every migration file is review material.
  • Removing an applied migration. migrations remove assumes the migration never ran anywhere; removing one that did desynchronizes every environment from the files.
  • Non-idempotent scripts in pipelines. A retried deployment then fails on statements that already ran; --idempotent turns reapplication into a no-op.
  • One-shot destructive changes with no compatibility window. Dropping a column that the previous app version still reads makes rollback impossible. Expand and contract keeps the rollback window real.
  • Hand-resolving snapshot conflicts. Hand-edited snapshots drift from the model and corrupt future diffs; remove and re-add through the tooling instead.

Key takeaways

  • A migration is a generated C# Up/Down pair plus a model snapshot. The diff against the snapshot is what the next migration will change.
  • Review every scaffold: renames arrive as drop-plus-add until you edit them, and data loss warnings are printed in the build output.
  • Production applies migrations once as a deployment step: an idempotent script or a bundle executed by the pipeline, not startup code racing itself across instances.
  • migrations remove is only safe for migrations no environment has applied; check migrations list first.
  • Snapshot conflicts regenerate: merge, remove, re-add; never edit the snapshot by hand.
  • For changes old code cannot tolerate, ship in phases. Expand the schema, backfill and deploy, contract later, so a rolling deploy and its rollback both stay possible.

FAQ

How do I add and apply an EF Core migration?

Run dotnet ef migrations add with a descriptive name after changing your model, which generates the Up and Down methods and updates the snapshot. Apply it locally with dotnet ef database update. Review the generated file before committing: renames need manual editing, and the scaffolder prints warnings when a change may lose data.

How do I generate a SQL script for a migration?

Run dotnet ef migrations script, optionally with –idempotent so reapplication is a no-op, and –from and –to to script the exact delta between two migration states. The output is the SQL that will run in production, so it goes through review like any change.

What is an EF Core migration bundle?

A single executable produced with dotnet ef migrations bundle that carries your migrations and applies them with a connection string, so the target environment needs no .NET SDK or EF tooling. Pipelines build the bundle as an artifact and run it as a deployment step before the new application version starts.

Why should I not call Database.Migrate() at startup?

Because with more than one instance, every instance races the others through the same migration at deployment time: locks block, some instances fail to start, and restart loops turn a schema change into an outage window. Apply migrations once as a pipeline step. Startup migration is acceptable only for tools that genuinely run as a single instance.

How do I rename a column without losing data in EF Core?

The scaffolder cannot tell a rename from a drop-plus-add, so edit the generated migration to use migrationBuilder.RenameColumn with the old and new names. The data loss warning in the build output is the signal to check. If it mentions your renamed column, the migration needs the edit before it ships.

Schema changes are deployments

Migrations make schema evolution ordinary code: generated, reviewed, versioned, and applied by a pipeline instead of a hero. Treat every migration file as a pull request artifact worth reading, apply them once before instances start. Stage incompatible changes with expand and contract so a rollback is always possible. Next, querying efficiently with EF Core covers the performance half of data access, and every guide in the series lives on the .NET series index. The rule of thumb: if a migration can delete data, it is a deployment, so review it, script it, and apply it like one.

Last updated on 8 November 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *