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.
Symptom / Error Signatures
Startup migration causes a recognisable set of symptoms during deploys:
- Pods stuck in
CrashLoopBackOffor 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
ALTERandCREATEprivileges 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.
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.
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.