4 min readBy Muhammad Shahid

EF Core Migrations in Production

How to run EF Core migrations in production without a startup race: generate a SQL script, apply it once in the pipeline, and use expand and contract when the app is already live.

Part of EF Core

Quick answers

Should ASP.NET Core call Database.Migrate on startup in production?
No, once more than one instance starts. Each instance races to apply the same migration. Run the SQL script once in the release pipeline, then start the new app.
What is an idempotent EF Core migration script?
dotnet ef migrations script --idempotent emits SQL that checks __EFMigrationsHistory and applies only pending migrations. Safe to run more than once. Still run it once per release, not per pod.
How do I rename a column without downtime?
Expand, then contract. Add the new column, write both from the app, backfill, switch reads, then drop the old column in a later migration. One migration that renames in place breaks the old app during the rollout.

Database.Migrate() on startup is fine for a single local process. It is a race in production: two App Service instances both see a pending migration and both try to apply it.

Hub: EF Core. SQL shape of these scripts: EF Core SQL performance.

Real-world analogy

You are renovating a shop that is still open. You build the new counter beside the old one, move the till across, then remove the old counter the next weekend. Sawing the only counter in half during opening hours is a rename that drops a column while the previous app version is still writing to it.

Worked example

Two App Service instances start after a deploy. Both run Database.Migrate() in Program.cs. Both see the same pending row, both try to add the column, and one fails on the migration history lock. Customers see 500s while the instances recycle and try again. The release pipeline should have applied one script before either instance started. Startup then does nothing.

// Do not call this from Program.cs once more than one instance boots.
// await db.Database.MigrateAsync();

// Pipeline, once per release:
// dotnet ef migrations script --idempotent --output migrate.sql

What to run, and when

Table: Place, Command, Use
PlaceCommandUse
Dev machinedotnet ef migrations addCreate the migration. Commit it.
Pipelinedotnet ef migrations script --idempotentOne SQL file
Pipeline, oncesqlcmd or your migratorApply that file before the new instances start
App startup in prodnothingDo not call Database.Migrate()

Keep dotnet ef database update for local databases. Production gets the script so the SQL is reviewable and applied once.

Expand and contract

A migration and the app deploy are not the same release when the old process is still running.

  1. Expand. Add a nullable column or a new table. Old code ignores it. Deploy the migration.
  2. Dual-write. New code writes old and new shapes. Deploy the app.
  3. Backfill. Copy existing rows.
  4. Switch reads to the new shape.
  5. Contract. Drop the old column in a later migration, after no running instance reads it.

A rename generated by EF Core is a drop plus an add unless you edit the migration. Treat generated renames as data loss until you have read the Up method.

Locks

Adding a non-nullable column with a default can lock a large SQL Server table. Add it nullable, backfill in batches, then alter to non-nullable in a follow-up. Index creates on big tables belong in a maintenance window, not inside a request-path deploy you expect to take seconds.

__EFMigrationsHistory is the source of truth. Do not hand-edit it to "skip" a failed migration. Fix the Up method or add a new migration that repairs the schema.

Seed and startup

Do not seed reference data with Migrate() side effects. Seed in the same pipeline step, or in an explicit idempotent script, so a second instance does not insert duplicate lookup rows. Concurrency tokens are a separate problem: optimistic concurrency.

If an interviewer asks

"Should ASP.NET Core call Database.Migrate on startup in production?"
No, once more than one instance starts. Each instance races to apply the same migration — one wins, others throw. Apply a SQL script once in the release pipeline before any new instance boots. Startup does nothing in production.

30-second answer: Generate an idempotent script with dotnet ef migrations script --idempotent, apply it once in the pipeline, then deploy. Remove Database.Migrate() from Program.cs.

Strong answer: Explains the startup race condition, the difference between database update (local only) and an idempotent script (production), the expand-and-contract pattern for zero-downtime column changes, and the risk of locking a large table when adding a non-nullable column with a default. Also mentions that __EFMigrationsHistory is the source of truth — never hand-edit it.