Entity Framework Core Migrations
Entity Framework Core makes schema changes feel like part of the C# code: edit an entity, run dotnet ef migrations add, and a migration class appears with Up and Down methods built from MigrationBuilder calls. For a single developer against a local database, dbContext.Database.Migrate() at application startup closes the loop. In production that same convenience is the source of most EF Core migration incidents. Ten replicas of a service start at once and all try to migrate; the migration that renamed a property drops a column and adds a new, empty one; a generated CreateIndex blocks writes on a large SQL Server or PostgreSQL table; and the deploy that fails halfway leaves the __EFMigrationsHistory table and the schema disagreeing. This part of ORM & Framework Migration Workflows covers how .NET teams run EF Core migrations as a controlled, reviewable, backward-compatible deploy step. It serves backend developers who author migrations and the platform engineers who run them against SQL Server, PostgreSQL (via Npgsql) and MySQL.
The central idea is to separate the authoring tool from the execution mechanism. EF Core is excellent at generating a first draft of a migration from model changes; production should execute reviewed SQL — an idempotent script or a migration bundle — as one gated step, with every change backward compatible with the code still running, following Expand and Contract Methodology.
Concept & Mechanism
An EF Core migration is a C# class deriving from Migration. Up and Down call methods on MigrationBuilder — AddColumn, RenameColumn, CreateIndex, Sql — which the provider (SQL Server, Npgsql, Pomelo for MySQL) translates into DDL. Alongside it, EF writes a model snapshot (<Context>ModelSnapshot.cs) that records the model as of the latest migration; the next migrations add diffs the current model against that snapshot. Applied migrations are recorded in __EFMigrationsHistory by migration ID.
EF Core offers three ways to apply migrations, and they behave very differently under load:
| Mechanism | How it runs | Transaction behaviour | Production suitability |
|---|---|---|---|
Database.Migrate() at startup |
every instance, on boot | per migration, where the provider supports it | poor: races, slow boots, crash loops |
dotnet ef database update |
from a machine with the SDK and source | per migration | acceptable for manual ops, awkward in CI |
SQL script (migrations script --idempotent) |
any SQL client, once | as written in the script | good: reviewable artefact |
migration bundle (migrations bundle) |
self-contained executable, once | per migration | good: no SDK needed on the runner |
The idempotent script deserves special mention: it wraps each migration in a check against __EFMigrationsHistory, so running the whole script against a database at any version applies exactly the missing migrations. That makes it safe to run in every environment and trivial to review — the SQL in the file is the SQL that will run.
Two generated-migration behaviours matter for zero downtime. First, EF Core cannot always tell a property rename from a drop plus an add; when it guesses wrong, the migration drops the old column and its data, and the scaffolding warns An operation was scaffolded that may result in the loss of data. Second, generated index and constraint operations use the provider’s default, blocking form: CreateIndex does not add CONCURRENTLY on PostgreSQL (unless you configure Npgsql’s IsCreatedConcurrently()) or ONLINE = ON on SQL Server.
Provider differences shape what “online” means. On SQL Server, most ALTER TABLE operations are transactional, adding a nullable column or a column with a constant default is a metadata-only change on modern editions, and index builds can run with ONLINE = ON on editions that support online index operations; without it, an index build blocks writes much like PostgreSQL’s plain CREATE INDEX. SQL Server’s lock behaviour is governed by SET LOCK_TIMEOUT (milliseconds, -1 meaning wait forever by default), which you can emit at the top of a migration with migrationBuilder.Sql. On PostgreSQL via Npgsql, migrations are transactional, CREATE INDEX CONCURRENTLY must run outside the transaction, and lock_timeout can be set per connection through the connection string’s Options parameter. On MySQL via Pomelo, DDL is not transactional at all, so a migration with several operations that fails halfway leaves the earlier operations applied and unrecorded; keeping one DDL operation per migration and using online algorithms (ALGORITHM=INPLACE, LOCK=NONE in raw SQL) avoids most surprises, as covered in avoiding implicit commits in MySQL DDL migrations.
EF Core’s Down methods deserve the same scepticism as any generated rollback. They are generated as the literal inverse of Up, which for an AddColumn means DropColumn — correct for schema, destructive for data. Keep them, because they make local development and CI round-trips convenient, but do not treat them as a production rollback plan without reading them. A CI step that applies every migration up, then down to the previous release, then up again, is a cheap way to prove the Down methods still match their Up counterparts after hand edits.
Prerequisites & Decision Criteria
Adopt the following before relying on the procedure.
| Requirement | Why | How |
|---|---|---|
Migrations applied by a pipeline step, not Migrate() in Program.cs |
avoids races and crash loops across replicas | remove startup migration; add a deploy job |
| Idempotent script or bundle produced in CI | reviewable, repeatable artefact | dotnet ef migrations script --idempotent or migrations bundle |
| A dedicated migration login | DDL privileges only where needed | separate connection string for the migration step |
| Lock/command timeouts for the migration connection | bounded lock waits | provider options or SET lock_timeout in the script |
| Model snapshot reviewed in PRs | catches accidental model changes | require snapshot diff review |
Review checklist for each EF Core migration:
Step-by-Step Procedure
1. Generate the migration and read the SQL, not just the C#. Produce the script for just the new migration and review it. Verify it contains only the statements you expect before proceeding.
# Shell · developer workstation with the .NET SDK and dotnet-ef tool · reads the model, touches no database
dotnet ef migrations add AddOrderRegion --project src/Shop.Data --startup-project src/Shop.Api
dotnet ef migrations script AddOrderPriority AddOrderRegion --project src/Shop.Data --startup-project src/Shop.Api
2. Edit the draft for online DDL and data safety. Replace blocking operations with raw SQL where needed. suppressTransaction: true tells EF to run that statement outside the migration’s transaction, which PostgreSQL requires for CREATE INDEX CONCURRENTLY.
// C# · Migrations/20260918101500_AddOrderRegionIndex.cs · PostgreSQL (Npgsql)
// WARNING: suppressTransaction runs this statement outside the migration transaction; keep it alone.
public partial class AddOrderRegionIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_orders_region ON orders (region);",
suppressTransaction: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_orders_region;", suppressTransaction: true);
}
}
// ROLLBACK PATH: the Down method drops the index concurrently.
3. Split destructive changes across releases. Removing or renaming a property becomes: release N stops using and mapping it (while the column remains), release N+1 drops the column. For renames, prefer an explicit RenameColumn only when all running code can tolerate it; otherwise use add-new, dual-write, switch, drop-old. See avoiding EF Core column rename data loss.
4. Produce the deploy artefact in CI. Either an idempotent script or a bundle; attach it to the build so the pipeline applies exactly what was reviewed, as in generating idempotent SQL scripts from EF Core and using EF Core migration bundles in CI.
5. Apply once, before rollout, with a lock timeout. Run the script or bundle as a single pipeline job using the migration login; only after it succeeds does the orchestrator roll out the new application version. Remove Database.Migrate() from startup, per running EF Core migrations outside application startup.
6. Verify the history table and the schema. Confirm __EFMigrationsHistory contains the new IDs and that no invalid indexes remain.
Verification & Observability
Check what EF believes before and after the deploy:
# Shell · CI or ops workstation · lists migrations and whether each is applied to the target database
dotnet ef migrations list --project src/Shop.Data --startup-project src/Shop.Api --connection "$MIGRATION_CONNECTION"
-- PostgreSQL · read-only · after the migration job
SELECT "MigrationId", "ProductVersion" FROM "__EFMigrationsHistory" ORDER BY "MigrationId" DESC LIMIT 5;
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
In CI, add a check that the model has no pending changes — EF Core 9 raises PendingModelChangesWarning as an error from Migrate() when the model differs from the latest snapshot, and dotnet ef migrations has-pending-model-changes (EF Core 8+) exits non-zero in the same situation. During the run, watch lock waits on the database as for any migration, using the queries in DDL Lock Management & Timeouts.
Rollback Path
EF Core can revert with dotnet ef database update <PreviousMigration> or with a reverse script (dotnet ef migrations script <From> <To> where To is earlier), running each migration’s Down. For additive migrations that is usually harmless; for anything that dropped or transformed data it is not — Down for an AddColumn drops the column and whatever was written to it.
# Shell · ops workstation · generates, does not run, the reverse script for review
# WARNING: review for DROP statements before applying; data written since the deploy may be lost.
dotnet ef migrations script AddOrderRegion AddOrderPriority --project src/Shop.Data --startup-project src/Shop.Api -o rollback.sql
Because the procedure keeps every migration backward compatible, the normal rollback is the application: redeploy the previous version and leave the additive schema change in place. Reverse a migration only when it is additive and empty. The pipeline mechanics are covered in Rollback Automation.
Common Errors & Fixes
An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy. Root cause: EF generated a DropColumn, a narrowing AlterColumn, or a drop/add pair for a rename. Fix: edit the migration — use RenameColumn or an expand/contract sequence — before committing.
The model for context 'ShopContext' has pending changes. Add a new migration before updating the database. Root cause: EF Core 9’s check that the model matches the latest snapshot. Fix: add the missing migration, or find the unintended model change; see fixing EF Core pending model changes errors.
42P07: relation "ix_orders_region" already exists on retry. Root cause: a previous run created the object but failed before recording the migration. Fix: make raw-SQL statements idempotent (IF NOT EXISTS), or use the idempotent script, and check for invalid indexes after a failed concurrent build.
Several instances fail at startup with lock or duplicate-object errors. Root cause: Database.Migrate() running concurrently from many replicas. Fix: move migration to a single pipeline step.
Child Page Index
Five guides cover the EF Core-specific mechanics. Generating idempotent SQL scripts from EF Core turns migrations into a reviewable artefact that is safe to run anywhere. Using EF Core migration bundles in CI packages them as an executable for runners without the SDK. Avoiding EF Core column rename data loss handles the drop-and-add trap. Running EF Core migrations outside application startup removes Migrate() from Program.cs safely. And fixing EF Core pending model changes errors explains the EF Core 9 check and how to satisfy it.
For the same problems in other ecosystems, compare Django Migrations Without Downtime and TypeORM Migration Workflows.
Frequently Asked Questions
Is it acceptable to call Database.Migrate() at startup in production?
Only for a single-instance application with a short, safe migration history. With multiple replicas, startup migration races, slows boots and turns a failed migration into a crash loop. Apply migrations once from a pipeline step instead.
What does --idempotent add to the generated script?
It wraps each migration’s statements in a check against __EFMigrationsHistory, so each migration runs only if its ID is not already recorded. The same script can then be applied to databases at any earlier version.
How do I create indexes online with EF Core?
Either configure the index in the model with the provider’s online option — Npgsql’s IsCreatedConcurrently() for PostgreSQL, IsCreatedOnline() for SQL Server — or write the statement with migrationBuilder.Sql(..., suppressTransaction: true). In both cases, keep that operation in its own migration.
Does EF Core detect property renames?
Sometimes, but not reliably. When it cannot tell a rename from a removal plus an addition, it generates a drop and an add and warns about possible data loss. Always read the generated migration and replace such pairs with RenameColumn or an expand/contract sequence.