Running EF Core Migrations Outside Application Startup

The service’s Program.cs has had the same three lines since the first sprint: create a scope, resolve the DbContext, call Database.Migrate(). It worked for a year. Then the service scaled to twelve replicas, a deploy shipped a migration that added an index to a large table, and all twelve new pods tried to run it at once. Eleven waited on the first one’s lock, their readiness probes timed out, Kubernetes restarted them, and they queued up again; the deploy took forty minutes and the old pods were drained halfway through. EF Core 9 added a database lock that serialises concurrent Migrate() calls, which fixes the race but not the underlying problem: a schema change is a one-off, deploy-level operation, and a process that serves traffic is the wrong place to run it. This guide moves migrations into a dedicated step and replaces startup migration with a startup check. It is part of Entity Framework Core Migrations.

Twelve Pods Migrating at Startup Timeline of a deploy with Migrate() in Program.cs. Pod 1 acquires the migration and runs a 6 minute index build. Pods 2 to 12 block waiting for it; their readiness probes fail at 2 minutes and they are restarted, repeatedly. Old pods are drained as the rollout proceeds, reducing capacity while no new pod is ready. Twelve Pods Migrating at Startup Pod 1 runs the migration (index build) ready Pods 2–12 blocked blocked blocked ready Old pods serving draining 0 2 min 4 min 6 min 8 min 10 min migrating waiting on migration restart / drain serving
Startup migration couples a long, one-off operation to readiness probes and rollout pacing — capacity drops while nothing new is ready.

Symptom / Error Signatures

Startup migration causes a recognisable set of symptoms during deploys:

  • Pods stuck in CrashLoopBackOff or failing readiness probes while one pod runs a long migration.
  • On EF Core 8 and earlier, concurrent Migrate() calls failing with duplicate-object errors (There is already an object named ..., 42P07) or primary-key violations on __EFMigrationsHistory.
  • On EF Core 9+, pods waiting on the migration lock and logging repeated attempts to acquire it.
  • A failed migration taking down every new pod, because each one retries it on boot and fails the same way.
  • The application’s own database login needing ALTER and CREATE privileges solely so it can migrate.

Root Cause Analysis

Database.Migrate() is designed for convenience: it applies pending migrations using the application’s DbContext, connection string and credentials. Placing it at startup couples three things that should be independent:

Concern At startup As a pipeline step
Concurrency every replica attempts it; serialised by a lock (EF 9+) or racing (earlier) exactly one runner
Duration bounded by readiness probe and rollout timeouts bounded by a job timeout you choose
Failure every new pod fails and restarts the pipeline stops; old pods keep serving
Privileges app login must hold DDL rights migration login holds them; app login does not
Ordering migration runs while old pods are being drained migration completes before any new pod starts

The ordering row is the one that matters most for zero downtime. With startup migration, the schema changes at an arbitrary point during the rollout, when some fraction of old pods has already been removed. With a pipeline step, the schema changes first, while the full old fleet is still serving, and new pods start only against a completed schema. Combined with backward-compatible migrations, that makes the whole rollout safe to pause, resume or roll back.

The Replacement: Migrate Step, Then Rollout Sequence between the pipeline, a migration job, the database and the Deployment. The pipeline runs the migration job once; it applies pending migrations and exits zero. Only then does the pipeline update the Deployment. New pods start, check that no migrations are pending, and become ready. The Replacement: Migrate Step, Then Rollout Pipeline Migration job Database New pods run efbundle / script once apply pending migrations exit 0 roll out new image GetPendingMigrations() → none ready
New pods only verify the schema; the pipeline guarantees it was migrated before they started.

Immediate Mitigation

1. Remove the migration call from startup. Keep everything else in Program.cs unchanged.

// C# · Program.cs · BEFORE (remove this block)
// using (var scope = app.Services.CreateScope())
// {
//     scope.ServiceProvider.GetRequiredService<ShopContext>().Database.Migrate();
// }

2. Add a one-shot migration step to the deploy. Use a migration bundle or an idempotent script, as described in using EF Core migration bundles in CI. Run it before the rollout and stop the pipeline if it fails.

# Shell · deploy pipeline step · migration login from a secret · runs exactly once per deploy
# WARNING: the rollout step below must depend on this step's success.
./efbundle --connection "$MIGRATION_CONNECTION" --verbose
kubectl set image deployment/shop-api api=registry.example.com/shop-api:"$GIT_SHA"
kubectl rollout status deployment/shop-api --timeout=15m

3. Replace startup migration with a startup check. New pods should refuse to become ready if migrations they expect are missing, rather than apply them. That turns a skipped migration step into a visible, safe failure.

// C# · Program.cs · AFTER · a fail-fast check, no DDL
// WARNING: this blocks readiness only for pods of the NEW version; old pods are unaffected.
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ShopContext>();
    var pending = db.Database.GetPendingMigrations().ToList();
    if (pending.Count > 0)
    {
        throw new InvalidOperationException(
            $"Database is missing migrations: {string.Join(", ", pending)}. Run the migration step first.");
    }
}

4. Revoke DDL privileges from the application login. Once nothing at runtime migrates, the application’s database user no longer needs ALTER, CREATE or ownership rights; the dedicated migration login keeps them.

Permanent Fix / Long-Term Pattern

The durable shape is a two-phase deploy: phase one applies migrations once, with a dedicated login, a lock timeout and a job-level timeout; phase two rolls out application pods that verify but never modify the schema. Every migration in phase one must be backward compatible with the version still running, which is the discipline set out in Entity Framework Core Migrations and the wider Migration Pipeline Gating section. Local development can keep a convenience path — for example, calling Migrate() only when app.Environment.IsDevelopment() — as long as production configuration can never reach it.

For services that other teams deploy from templates, bake this into the template: a migration job, a startup pending-migrations check, and separate connection strings for migration and runtime. If you run on a platform without jobs, a pipeline step with a single runner achieves the same result. Record migration duration and outcome per deploy, following tracking schema migration metrics and SLOs.

Who Does What After the Change Matrix of responsibilities in the two-phase deploy. The migration job applies DDL, holds DDL privileges and runs once. Application pods check pending migrations, hold only data privileges and run many replicas. Local development may still call Migrate for convenience. Who Does What After the Change Component Schema action Privileges Instances Migration job applies migrations DDL (migration login) one per deploy Application pods checks pending, never applies DML only (app login) many Local development Migrate() allowed local superuser developer machine
Separating the roles also separates the privileges — the runtime login can no longer change the schema at all.

Verification Checklist

Frequently Asked Questions

EF Core 9 locks migrations — isn’t startup migration safe now? The lock prevents concurrent Migrate() calls from corrupting each other, which fixes the race. It does not fix the other problems: long migrations still hold up readiness, failures still crash-loop every new pod, the schema still changes mid-rollout, and the application login still needs DDL rights.

What about EnsureCreated()? Never use it on a database managed by migrations. It creates the schema from the current model without recording migrations, so later Migrate() or bundle runs fail because the objects already exist.

How do old pods cope with the migration running before them? They keep serving because every migration is backward compatible: additive changes are invisible to old code, and destructive changes are deferred until a release after the code stopped using the affected objects.

Is a startup check expensive? GetPendingMigrations() reads __EFMigrationsHistory once and compares it with the migrations compiled into the assembly. It is a single small query at boot.