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
| Place | Command | Use |
|---|---|---|
| Dev machine | dotnet ef migrations add | Create the migration. Commit it. |
| Pipeline | dotnet ef migrations script --idempotent | One SQL file |
| Pipeline, once | sqlcmd or your migrator | Apply that file before the new instances start |
| App startup in prod | nothing | Do 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.
- Expand. Add a nullable column or a new table. Old code ignores it. Deploy the migration.
- Dual-write. New code writes old and new shapes. Deploy the app.
- Backfill. Copy existing rows.
- Switch reads to the new shape.
- 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.