Stop Running EF Core Migrations on Startup (And How to Do It Right)

2026-07-05

Let me guess how your current ASP.NET Core application handles Entity Framework Core migrations. I'm willing to bet your Program.cs has a block of code that looks exactly like this:

// 🚨 THE TICKING TIME BOMB
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    db.Database.Migrate(); // <-- Please stop doing this
}

I get it. It’s convenient. When you deploy the app, it automatically updates the database. Magic, right?

I used to do this too—until the day we scaled our web app to run on three instances behind a load balancer. When a new deployment hit, all three instances spun up simultaneously. They all checked the __EFMigrationsHistory table, saw a pending migration, and tried to alter the database schema at the exact same millisecond.

The result? A massive database deadlock, a corrupted migrations table, and a Friday night spent manually restoring a database backup.

Here is why applying migrations on startup is a terrible idea, and how you should actually be doing it in production.

Why Database.Migrate() is Dangerous

  1. The Concurrency Nightmare: As mentioned above, if you have multiple instances of your app starting up, they will race to apply the migration. EF Core does not natively lock the database to prevent this.
  2. Security Risks (Principle of Least Privilege): Your web application’s database connection string should only have DML permissions (SELECT, INSERT, UPDATE, DELETE). It should never have DDL permissions (ALTER TABLE, DROP, CREATE). If your app gets SQL injected, you do not want the attacker having the rights to drop your tables. But Database.Migrate() requires full DDL admin rights.
  3. Hidden Failures: If a migration takes 2 minutes to run (e.g., adding an index to a 10-million row table), your app is blocked from starting up. The load balancer's health check will fail, and it will kill the container, leaving your database in an unknown state.

The Professional Way: Idempotent SQL Scripts

You shouldn't let your application modify the database schema. That is the job of your CI/CD pipeline (GitHub Actions, Azure DevOps, etc.).

Instead of executing the migration, you should generate an idempotent SQL script during your build phase. Idempotent means the script checks if the migration has already been applied before running it, making it perfectly safe to run multiple times.

You can generate this easily using the EF Core CLI:

dotnet ef migrations script --idempotent --output ./migrations.sql

Look at the SQL it generates. It wraps your changes in IF statements:

IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20260705_AddUsersTable')
BEGIN
    CREATE TABLE [Users] (
        [Id] uniqueidentifier NOT NULL,
        [Email] nvarchar(max) NOT NULL,
        CONSTRAINT [PK_Users] PRIMARY KEY ([Id])
    );

    INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
    VALUES (N'20260705_AddUsersTable', N'8.0.0');
END;
GO

The CI/CD Workflow

Your deployment pipeline should now look like this:

  1. Build Step: Run dotnet ef migrations script to generate migrations.sql.
  2. Deploy Step (Database): Execute migrations.sql against the production database using a dedicated admin connection string.
  3. Deploy Step (App): Deploy the compiled web application using a locked-down, read/write connection string.

Dealing with Data Loss (The "Drop Column" Trap)

Even with SQL scripts, you need to watch out for EF Core's auto-generated Up() methods.

Let's say you rename a property from FirstName to GivenName. EF Core doesn't realize you renamed it. It thinks you deleted one property and created a new one.

// What EF Core generates (DATA LOSS!)
migrationBuilder.DropColumn(name: "FirstName", table: "Users");
migrationBuilder.AddColumn<string>(name: "GivenName", table: "Users", nullable: true);

If you run this, you just permanently deleted everyone's first name. You must always review the generated migration file and manually fix it:

// What you should manually change it to
migrationBuilder.RenameColumn(name: "FirstName", table: "Users", newName: "GivenName");

Wrap Up

Stop treating database migrations as an afterthought tacked onto your app's startup routine. Treat them as a discrete deployment artifact. Generate the SQL, review it, and let your pipeline handle the execution. Your future self (and your production data) will thank you.