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 and Migrate take 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:

Bash
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_ARGS

Reviewing 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:

C#
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:

StrategyBest forSQL reviewable before runningNeeds SDK and source at deploy timeEF migration locking
Idempotent SQL scriptDBA review, change-approval gatesYesNoNo
Migration bundleAutomated pipelinesNoNoYes
dotnet ef database updateLocal development and test databasesNoYesYes
Migrate() at runtimeSmall apps that accept the trade-offsNoNoYes

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.

Applying Migrations in CI/CD, Not at App Startup#

Calling Database.MigrateAsync() in Program.cs is tempting, and EF Core 9's lock makes concurrent startup migrations safer than they used to be. It is still the wrong default for production:

  • The application identity needs permission to alter the schema, which violates least privilege.
  • Nobody reviews the SQL before it runs against production.
  • A slow or failing migration now delays or crashes application startup, often across every replica at once, and health checks start failing.
  • Coordinating "migrate, then roll out the app" becomes implicit and hard to reason about.

Instead, build the bundle and script once in CI, then run the bundle as a gated, one-shot deployment step with a separate, schema-privileged identity before the application rollout begins:

YAML
name: release

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      EF_ARGS: --project src/Shop.Data --startup-project src/Shop.Api
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - run: dotnet tool restore        # dotnet-ef pinned in .config/dotnet-tools.json
      - run: dotnet test tests/Shop.Data.Tests
      - run: dotnet ef migrations has-pending-model-changes $EF_ARGS
      - run: dotnet ef migrations script --idempotent -o artifacts/migrate.sql $EF_ARGS
      - run: >
          dotnet ef migrations bundle --self-contained --target-runtime linux-x64
          -o artifacts/efbundle $EF_ARGS
        env:
          ASPNETCORE_ENVIRONMENT: Production
      - uses: actions/upload-artifact@v4
        with:
          name: db-migrations
          path: artifacts/

  migrate:
    needs: build
    runs-on: ubuntu-latest
    environment: production               # approvals and the migrator secret live here
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: db-migrations
      - run: chmod +x efbundle && ./efbundle --connection "$MIGRATOR_CONNECTION"
        env:
          ASPNETCORE_ENVIRONMENT: Production
          MIGRATOR_CONNECTION: ${{ secrets.DB_MIGRATOR_CONNECTION }}

  deploy-app:
    needs: migrate
    runs-on: ubuntu-latest
    steps:
      - run: echo "Roll out the application only after the schema is ready"

On Kubernetes, run the bundle as a Job or pre-upgrade hook rather than from every pod's entrypoint, and make sure the platform does not restart it after success. .NET Aspire provides an EF Core migrations integration that coordinates migrations locally and can publish a bundle or script for deployment. Pipeline structure is covered in CI/CD for .NET with GitHub Actions and Azure DevOps, and orchestration in .NET Aspire: Cloud-Native Orchestration.

The ordering rule that makes this safe is simple: every migration must work with the application version currently in production and with the version about to be deployed. Migrate first, then roll out the app. The rest of this guide is about writing migrations that satisfy that rule.

The Expand-Contract Pattern#

Expand-contract, also called parallel change, splits a breaking change into several individually compatible steps. You first expand the schema so it supports both the old and new shapes, migrate code and data across, and only then contract by removing the old shape. Each step ships in its own release, and each release can be rolled back without touching the database.

Consider renaming Customers.Name to DisplayName in a service that deploys with rolling updates:

ReleaseMigrationApplication behaviorRollback to previous app
1. ExpandAdd nullable DisplayNameWrites both columns; reads DisplayName ?? NameSafe: old code ignores the new column
2. BackfillNone (batched job)UnchangedSafe
3. SwitchName becomes nullable; DisplayName becomes requiredReads and writes only DisplayNameSafe: release 1 code still works
4. ContractDrop NameUnchangedOnly to release 3

Release 1 adds the column and teaches the application to dual-write:

C#
// Release 1 migration: purely additive, safe while the old version is still running
public partial class ExpandCustomerDisplayName : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
        => migrationBuilder.AddColumn<string>(
            name: "DisplayName", table: "Customers", schema: "sales",
            maxLength: 200, nullable: true);

    protected override void Down(MigrationBuilder migrationBuilder)
        => migrationBuilder.DropColumn(name: "DisplayName", table: "Customers", schema: "sales");
}

// Release 1 application code: write both, read the new column with a fallback
customer.Name = input.DisplayName;
customer.DisplayName = input.DisplayName;

var names = await db.Customers
    .Select(c => new { c.Id, Name = c.DisplayName ?? c.Name })
    .ToListAsync(ct);

In release 3 you remove the Name property from the entity, but if you simply delete it, the next scaffolded migration drops the column immediately and breaks any release 1 instances still draining. Keep the column in the model as a nullable shadow property until release 4:

C#
// Release 3 model: the CLR property is gone, the column stays (now nullable) until release 4
modelBuilder.Entity<Customer>().Property<string?>("Name").HasMaxLength(200);
modelBuilder.Entity<Customer>().Property(c => c.DisplayName).IsRequired().HasMaxLength(200);

// Release 4 model: delete the shadow property; the scaffolded migration is a single DropColumn

Making DisplayName required validates every existing row, so on a large table schedule it for a quiet period. PostgreSQL 12 and later can skip that scan when a validated CHECK (col IS NOT NULL) constraint already exists. The same four-step shape handles splitting a column, moving data to a new table or changing a column's type.

Backward-Compatible Schema Changes#

Not every change needs four releases. The table below classifies common changes by their risk during a rolling deployment:

ChangeWhy it breaksZero-downtime approach
Add nullable columnRarely breaksSingle migration before the app rollout
Add required columnOld code inserts rows without itAdd with a default or as nullable, backfill, then tighten
Rename column or tableOld code uses the old nameExpand-contract
Drop columnOld code still selects it, because EF Core lists every mapped columnRemove it from the model in one release, drop it in a later one
Change column typeTable rewrite, locks, conversion failuresNew column, backfill, switch, drop
Add indexBlocks writes during the buildIsCreatedOnline() on SQL Server, IsCreatedConcurrently() on PostgreSQL
Add foreign key or check constraintValidates every row under lockAdd 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:

SQL
-- 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:

C#
// 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:

C#
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:

C#
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");
}
AspectEF Core migrationsDbUpFluentMigrator
AuthoringGenerated from the EF model, editable C#Hand-written SQL scriptsC# fluent DSL, raw SQL allowed
History table__EFMigrationsHistorySchemaVersionsVersionInfo
RollbackDown methods, bundle or script to a targetForward-onlyDown methods, MigrateDown, Rollback
Deployment artifactBundle or idempotent scriptConsole appRunner app or dotnet-fm tool
Best fitApps whose model lives in EF CoreSQL-first and DBA-led teamsCode-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 Down migrations deliberately. A bundle accepts a target, ./efbundle PreviousMigration, and dotnet ef migrations script Newer Older generates rollback SQL for review. Rolling back executes every newer Down method and can lose data written since the upgrade.
  • Make irreversible steps explicit. When a data transformation cannot be undone, have Down throw 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.

C#
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-changes and 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 DbContext inside a migration, which breaks when entity types change later.
  • Assuming Down works 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.

Further Reading#