Database migrations are versioned, repeatable scripts that move a schema from one state to the next, and zero-downtime schema changes are migrations designed so that users never notice them. This guide is for .NET teams that deploy continuously and cannot take the application offline for a release. It covers the EF Core migrations workflow, migration bundles and idempotent scripts, running migrations from CI/CD instead of at startup, the expand-contract pattern, backward-compatible changes, data backfills, DbUp and FluentMigrator, rollback strategies and how to test migrations before production does it for you.
What Are Database Migrations?#
A migration describes a transition, not a final state: add this column, copy that data, create this index. Applying migrations in order, and recording which ones ran, lets every environment converge on the same schema from wherever it started. EF Core, DbUp and FluentMigrator all work this way, differing mainly in how you author the transitions: EF Core generates them from your model, DbUp runs plain SQL scripts, and FluentMigrator uses a C# DSL.
Zero downtime adds a constraint that migrations alone do not solve. During a rolling deployment, the old and new versions of your application run side by side against one database, and a migration runs while both are serving traffic. Every schema change must therefore be compatible with at least two application versions at once. Most of this guide is about honoring that rule without slowing delivery down.
How EF Core Migrations Work#
When you run dotnet ef migrations add, EF Core compares your current model with the model snapshot stored in the project and scaffolds three files: the migration with Up and Down methods, a designer file with metadata, and an updated ModelSnapshot. Applying a migration executes its operations and inserts a row into the __EFMigrationsHistory table, which is how EF Core knows what has already run.
Several behaviors changed in recent releases, and they matter for automation:
- Transactions. Each migration normally runs in its own transaction. EF Core 9 briefly wrapped all pending migrations in one transaction, and EF Core 10 reverted that. Operations that cannot run in a transaction, such as some index builds, opt out with
suppressTransaction: true. - Locking. Since EF Core 9,
database update, bundles andMigratetake a database-wide lock, so two processes cannot apply migrations concurrently. SQL scripts run outside EF Core and are not locked. - Pending model changes. Since EF Core 9, applying migrations throws if the model has changes that no migration captures.
- Team safety. EF Core 11 records the latest migration ID in the snapshot, so two branches that each add a migration produce a merge conflict instead of silently diverging.
Getting Started: The EF Core Migrations Workflow#
The day-to-day loop is short. Change the model, scaffold a migration, read it, and commit it with the code that needs it:
EF_ARGS="--project src/Shop.Data --startup-project src/Shop.Api"
# Scaffold a migration from model changes, then review the generated code
dotnet ef migrations add AddCustomerDisplayName $EF_ARGS
# Fail fast when someone changed the model without adding a migration (exits with an error)
dotnet ef migrations has-pending-model-changes $EF_ARGS
# Preview the SQL that production will run
dotnet ef migrations script --idempotent --output artifacts/migrate.sql $EF_ARGS
# Apply locally
dotnet ef database update $EF_ARGSReviewing is not optional. EF Core cannot tell a rename from a drop-and-add, so renaming a property scaffolds DropColumn plus AddColumn, which deletes the data. The scaffolder warns about possible data loss; when you see that warning, edit the migration:
public partial class RenameSkuColumn : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Scaffolded as DropColumn + AddColumn; replaced to keep the data
migrationBuilder.RenameColumn(
name: "ProductCode", table: "Products", schema: "catalog", newName: "Sku");
// Index builds that must not run inside a transaction opt out explicitly
migrationBuilder.Sql(
"CREATE INDEX IX_Products_Sku ON catalog.Products (Sku) WITH (ONLINE = ON);",
suppressTransaction: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP INDEX IX_Products_Sku ON catalog.Products;",
suppressTransaction: true);
migrationBuilder.RenameColumn(
name: "Sku", table: "Products", schema: "catalog", newName: "ProductCode");
}
}Note that a plain rename is still a breaking change for the application version that is currently running; the expand-contract section below shows how to rename without downtime. The online index example is SQL Server syntax, and ONLINE = ON requires an edition that supports online index operations. In the model you can express the same intent with IsCreatedOnline() on SQL Server or IsCreatedConcurrently() with the Npgsql provider.
Migration Bundles and Idempotent Scripts#
A migration only helps if you can apply it reliably in every environment. EF Core offers four ways to do that, and the EF team's own guidance is clear about which fits where:
| Strategy | Best for | SQL reviewable before running | Needs SDK and source at deploy time | EF migration locking |
|---|---|---|---|---|
| Idempotent SQL script | DBA review, change-approval gates | Yes | No | No |
| Migration bundle | Automated pipelines | No | No | Yes |
dotnet ef database update | Local development and test databases | No | Yes | Yes |
Migrate() at runtime | Small apps that accept the trade-offs | No | No | Yes |
An idempotent script (dotnet ef migrations script --idempotent) checks the history table before each migration, so it can run against a database at any earlier migration. It is the right artifact when a DBA must read or adjust the SQL. Support depends on the provider; SQLite, for example, cannot generate idempotent scripts.
A migration bundle (dotnet ef migrations bundle) is a single executable that contains your migrations and applies whichever are pending, exactly like database update, without the .NET SDK, the EF tools or your source code on the deployment agent. A self-contained bundle does not even need the .NET runtime. Bundles use EF Core's migration lock and run your UseSeeding logic, but they cannot show you their SQL, so pair them with a generated script when reviews are required. Because bundles execute your startup code to build the context, set ASPNETCORE_ENVIRONMENT explicitly when you build and run them, and pass the connection string on the command line from a secret store rather than baking it into configuration files.
Backward-Compatible Schema Changes#
Not every change needs four releases. The table below classifies common changes by their risk during a rolling deployment:
| Change | Why it breaks | Zero-downtime approach |
|---|---|---|
| Add nullable column | Rarely breaks | Single migration before the app rollout |
| Add required column | Old code inserts rows without it | Add with a default or as nullable, backfill, then tighten |
| Rename column or table | Old code uses the old name | Expand-contract |
| Drop column | Old code still selects it, because EF Core lists every mapped column | Remove it from the model in one release, drop it in a later one |
| Change column type | Table rewrite, locks, conversion failures | New column, backfill, switch, drop |
| Add index | Blocks writes during the build | IsCreatedOnline() on SQL Server, IsCreatedConcurrently() on PostgreSQL |
| Add foreign key or check constraint | Validates every row under lock | Add without validation, validate separately |
The last row deserves an example. Both major engines let you add a constraint that only applies to new rows and validate history later with a lighter lock:
-- PostgreSQL: enforce for new rows now, validate existing rows without blocking writes
ALTER TABLE orders ADD CONSTRAINT fk_orders_customers
FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customers;
-- SQL Server: add without checking existing rows, then check them in a separate step
ALTER TABLE sales.Orders WITH NOCHECK
ADD CONSTRAINT FK_Orders_Customers FOREIGN KEY (CustomerId) REFERENCES sales.Customers (Id);
ALTER TABLE sales.Orders WITH CHECK CHECK CONSTRAINT FK_Orders_Customers;Put statements like these in migrationBuilder.Sql calls, split across migrations where the validation step is long-running. Also set a lock timeout for DDL, such as SET LOCK_TIMEOUT on SQL Server or lock_timeout on PostgreSQL, so a migration that cannot get its lock fails fast instead of queuing behind a long transaction while every new query queues behind it.
Data Migrations and Backfills#
EF Core migrations can move data as well as schema. The EF documentation recommends InsertData, UpdateData and DeleteData for fixed values, migrationBuilder.Sql for values computed from existing data, branching on migrationBuilder.ActiveProvider when you support several databases. Never use your current DbContext or entity classes inside a migration: historical migrations must keep compiling and behaving the same after those types change.
Inline data migrations suit small tables. For large tables, a single UPDATE holds locks for the whole statement, bloats the transaction log and runs inside the migration's transaction, so move the backfill out of the migration into a batched, resumable job:
// Idempotent, resumable backfill: safe to stop and restart at any time
const int batchSize = 5_000;
int updated;
do
{
updated = await db.Database.ExecuteSqlAsync($"""
UPDATE TOP ({batchSize}) sales.Customers
SET DisplayName = Name
WHERE DisplayName IS NULL AND Name IS NOT NULL
""", ct);
logger.LogInformation("Backfilled {Count} customers", updated);
await Task.Delay(TimeSpan.FromMilliseconds(200), ct); // leave headroom for live traffic
}
while (updated > 0);Each batch commits on its own, so locks are short, and the WHERE clause makes the job idempotent. Run it as a background worker or a one-off job after release 1 is fully deployed, and verify completion with a count query before the switch release.
Alternatives: DbUp and FluentMigrator#
EF Core migrations are the natural choice when EF Core owns the model. When it does not, for instance with Dapper-based services, database-first teams or DBA-authored SQL, two mature libraries fill the gap.
DbUp runs plain SQL scripts, typically embedded in a small console app, in name order, and records each script in a SchemaVersions journal table. It is deliberately forward-only: there are no down scripts, and you fix mistakes by adding a new script. It runs without transactions by default, with opt-in per-script or single-transaction modes:
using System.Reflection;
using DbUp;
var connectionString = args.FirstOrDefault()
?? throw new ArgumentException("Pass the connection string as the first argument.");
var upgrader = DeployChanges.To
.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.WithTransactionPerScript()
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
return result.Successful ? 0 : 1;FluentMigrator expresses migrations as C# classes ordered by a numeric version, with Up and Down methods and a fluent DSL that generates provider-specific SQL. Applied versions are tracked in a VersionInfo table, and the runner can migrate up, migrate down to a version or roll back a number of steps:
using FluentMigrator;
using FluentMigrator.Runner;
using Microsoft.Extensions.DependencyInjection;
// Deployment console app: apply every pending migration
var connectionString = args.FirstOrDefault()
?? throw new ArgumentException("Pass the connection string as the first argument.");
using var provider = new ServiceCollection()
.AddFluentMigratorCore()
.ConfigureRunner(rb => rb
.AddSqlServer()
.WithGlobalConnectionString(connectionString)
.ScanIn(typeof(AddCustomerDisplayName).Assembly).For.Migrations())
.BuildServiceProvider();
using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredService<IMigrationRunner>().MigrateUp();
[Migration(2026_09_24_1200)]
public sealed class AddCustomerDisplayName : Migration
{
public override void Up()
=> Alter.Table("Customers").InSchema("sales")
.AddColumn("DisplayName").AsString(200).Nullable();
public override void Down()
=> Delete.Column("DisplayName").FromTable("Customers").InSchema("sales");
}| Aspect | EF Core migrations | DbUp | FluentMigrator |
|---|---|---|---|
| Authoring | Generated from the EF model, editable C# | Hand-written SQL scripts | C# fluent DSL, raw SQL allowed |
| History table | __EFMigrationsHistory | SchemaVersions | VersionInfo |
| Rollback | Down methods, bundle or script to a target | Forward-only | Down methods, MigrateDown, Rollback |
| Deployment artifact | Bundle or idempotent script | Console app | Runner app or dotnet-fm tool |
| Best fit | Apps whose model lives in EF Core | SQL-first and DBA-led teams | Code-first teams without EF Core, multi-database products |
The zero-downtime discipline is identical whichever tool you choose: the tool orders and records transitions, but you design them.
Rollback Strategies#
Plan rollback before you need it, and prefer designs where you never roll back the database at all:
- Roll the application back, not the schema. With expand-contract, the previous app version works against the new schema, so reverting code is instant and safe.
- Roll forward with a corrective migration when a migration is wrong. Never delete or edit a migration that has reached a shared database.
- Use
Downmigrations deliberately. A bundle accepts a target,./efbundle PreviousMigration, anddotnet ef migrations script Newer Oldergenerates rollback SQL for review. Rolling back executes every newerDownmethod and can lose data written since the upgrade. - Make irreversible steps explicit. When a data transformation cannot be undone, have
Downthrow and document that rollback means restoring data, as the EF documentation recommends. - Take a backup or confirm point-in-time restore before contract steps that drop data.
Testing Migrations#
Migrations are code that runs once per environment with production data at stake, so test them like code. A good suite catches the three most common failures: a forgotten migration, a migration that fails on a real engine, and a Down that no longer works.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.MsSql;
using Xunit;
public sealed class MigrationTests
{
private const string SqlImage = "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04";
private static ShopDbContext CreateContext(string connectionString) =>
new(new DbContextOptionsBuilder<ShopDbContext>().UseSqlServer(connectionString).Options);
[Fact]
public void Model_matches_latest_migration()
{
using var db = CreateContext("Server=unused;Database=unused");
Assert.False(db.Database.HasPendingModelChanges());
}
[Fact]
public async Task Migrations_apply_and_newest_migration_reverts()
{
await using var sql = new MsSqlBuilder(SqlImage).Build();
await sql.StartAsync();
await using var db = CreateContext(sql.GetConnectionString());
await db.Database.MigrateAsync();
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
var migrator = db.GetInfrastructure().GetRequiredService<IMigrator>();
var all = db.Database.GetMigrations().ToList();
await migrator.MigrateAsync(all[^2]); // run the newest Down
await migrator.MigrateAsync(all[^1]); // and Up again
}
}Beyond these, run the idempotent script twice against a copy of production to prove it is really idempotent, time each migration on production-sized data to find long locks, and run your integration tests against the migrated database. Testcontainers setup is covered in Integration Testing ASP.NET Core with WebApplicationFactory and Testcontainers.
Best Practices#
- Ship additive migrations first and destructive ones last, at least one release apart.
- Review every generated migration and the SQL it produces, especially after renames.
- Gate CI on
has-pending-model-changesand a migration test against a real engine. - Deploy with bundles or idempotent scripts from the pipeline, using a separate schema-privileged identity.
- Keep migrations small and single-purpose so failures are easy to diagnose and locks are short.
- Move large backfills out of migrations into batched, idempotent jobs.
- Use online or concurrent index builds and unvalidated constraints on large tables.
- Set lock timeouts for DDL so migrations fail fast instead of blocking production.
Common Pitfalls#
- Accepting a scaffolded drop-and-add for a rename, which silently deletes data.
- Dropping a column the running app still maps, which breaks every query on that entity.
- Running
Migrate()from every replica at startup, tying app availability to schema changes. - Adding a required column without a default, which breaks inserts from the old version.
- Deleting applied migrations or editing them in place, which desynchronizes environments.
- Using the current
DbContextinside a migration, which breaks when entity types change later. - Assuming
Downworks without ever running it. - Merging parallel migrations by renaming files, which corrupts the snapshot chain.
Frequently Asked Questions#
Should I apply EF Core migrations at application startup?#
Not for production systems that need high availability or least privilege. Since EF Core 9 a database lock prevents concurrent startup migrations from corrupting the schema, but the application still needs DDL permissions, nobody reviews the SQL, and a slow migration blocks every replica's startup. Apply a bundle or idempotent script as a separate pipeline step instead.
What is the difference between a migration bundle and an idempotent script?#
A bundle is an executable that applies pending migrations using EF Core itself, with migration locking and seeding, and needs no SDK or source code. An idempotent script is plain SQL that checks the history table before each migration, which makes it reviewable and easy to hand to a DBA. Many teams generate both in CI: the script for review, the bundle for execution.
How do I rename a column without downtime?#
Use expand-contract. Add the new column, deploy code that writes both columns, backfill existing rows, switch reads and writes to the new column, and drop the old column in a later release. A direct RenameColumn is only safe when no running application version uses the old name.
Can EF Core migrations run large data backfills?#
They can, but they should not for large tables. A migration runs its SQL in one transaction, so a big UPDATE holds locks and grows the transaction log for its whole duration. Keep small reference-data changes in migrations and move large backfills into a batched, resumable job.
Should I choose DbUp or FluentMigrator instead of EF Core migrations?#
Choose EF Core migrations when EF Core owns your model, because they are generated from it and stay in sync. Choose DbUp when your team prefers writing SQL and wants forward-only scripts, and FluentMigrator when you want code-based migrations with Down support but do not use EF Core. The zero-downtime techniques are the same with every tool.
Summary#
- Migrations are ordered transitions; zero downtime requires each one to work with two application versions at once.
- Generate idempotent scripts and bundles in CI, and apply them before the app rollout with a dedicated identity.
- Use expand-contract for renames, type changes and drops, and keep destructive steps for a later release.
- Move large backfills into batched jobs, and use online index builds and unvalidated constraints.
- Test migrations against a real engine, including
Down, and prefer rolling the app back over rolling the schema back.