Zero-Downtime Database Migrations With EF Core

Zero-Downtime Database Migrations With EF Core

7 min read··

devopsdotnetef-core

Zero-downtime migrations come from the expand-contract pattern: add the new structure, migrate data and code onto it, then drop the old structure once nothing uses it. Each phase deploys and reverses independently, so old and new code always find a compatible schema.

Application code and database schema do not switch versions at exactly the same instant. During a rolling deployment, old and new instances must both work against the same intermediate schema.

The Deployment Problem

In a typical deployment, you update the database schema and deploy new application code at the same time. If the migration takes 30 seconds but the deployment takes 2 minutes, there's a window where the old code runs against the new schema - or the new code runs against the old schema.

Either scenario can cause errors. Renaming a column breaks the old code. Removing a column breaks queries that reference it. This is why naive migrations cause downtime.

The Expand-Contract Pattern

The expand-contract pattern splits every breaking change into three safe steps:

  1. Expand - add the new structure alongside the old one
  2. Migrate - move data and update code to use the new structure
  3. Contract - remove the old structure once nothing depends on it

Each step is deployed independently. At no point does old code break, because the old structure is still present during the expand and migrate phases.

The expand-contract pattern in three phases: expand adds the new column alongside the old, migrate copies data and switches code over, and contract drops the old column once nothing uses it

Additive-Only Migrations

The safest migrations only add things:

// ✅ Safe - additive changes
migrationBuilder.AddColumn<string>(
    name: "PhoneNumber",
    table: "Customers",
    nullable: true); // Must be nullable or have a default

migrationBuilder.CreateTable(
    name: "CustomerPreferences",
    columns: table => new
    {
        Id = table.Column<Guid>(),
        CustomerId = table.Column<Guid>(),
        Theme = table.Column<string>(defaultValue: "light")
    });

migrationBuilder.CreateIndex(
    name: "IX_Orders_CustomerId",
    table: "Orders",
    column: "CustomerId");

Adding nullable columns and new tables is normally compatible with old code because it ignores the new structures. Index creation is logically additive but can still block writes or consume substantial resources, so use the provider's online or concurrent option and test it on production-scale data.

// ❌ Dangerous - breaking changes
migrationBuilder.DropColumn(name: "Phone", table: "Customers");
migrationBuilder.RenameColumn(
    name: "Name", table: "Products", newName: "Title");
migrationBuilder.AlterColumn<string>(
    name: "Email", table: "Customers", nullable: false);

Dropping columns, renaming columns, and making nullable columns required are all breaking changes. They need the expand-contract pattern.

How Do You Rename a Column Without Downtime?

Renaming a column is one of the most common breaking changes. Here's how to do it safely with EF Core across three deployments.

Step 1: Expand - Add the New Column

public partial class AddTitleColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "Title",
            table: "Products",
            nullable: true);

        migrationBuilder.Sql(
            @"UPDATE ""Products"" SET ""Title"" = ""Name""");
    }
}

Deploy this migration. Both Name and Title columns exist. Old code uses Name, new code doesn't exist yet.

Step 2: Migrate - Write to Both, Read From New

Update your DbContext configuration to map the entity to the new column:

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.Property(p => p.Title)
            .HasColumnName("Title");

        // Ignore the old property in the model
        builder.Ignore(p => p.Name);
    }
}

If other services still write to the old column, add a trigger or application-level sync to keep both columns in sync:

public partial class SyncTitleAndName : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(@"
            CREATE OR REPLACE FUNCTION sync_product_title()
            RETURNS TRIGGER AS $$
            BEGIN
                IF TG_OP = 'INSERT' THEN
                    IF NEW.""Title"" IS NULL THEN
                        NEW.""Title"" = NEW.""Name"";
                    ELSIF NEW.""Name"" IS NULL THEN
                        NEW.""Name"" = NEW.""Title"";
                    END IF;
                ELSIF NEW.""Title"" IS DISTINCT FROM OLD.""Title"" THEN
                    NEW.""Name"" = NEW.""Title"";
                ELSIF NEW.""Name"" IS DISTINCT FROM OLD.""Name"" THEN
                    NEW.""Title"" = NEW.""Name"";
                END IF;
                RETURN NEW;
            END;
            $$ LANGUAGE plpgsql;

            CREATE TRIGGER trg_sync_product_title
            BEFORE INSERT OR UPDATE ON ""Products""
            FOR EACH ROW EXECUTE FUNCTION sync_product_title();
        ");
    }
}

The trigger must fire on INSERT OR UPDATE, not just updates. Old code inserts rows with only Name set, new code inserts rows with only Title set, and an update-only trigger would leave the other column NULL - exactly the inconsistency this whole dance exists to prevent.

Deploy. All code works - old code reads/writes Name, new code reads/writes Title, and the trigger keeps them in sync.

Step 3: Contract - Remove the Old Column

Once no running code references Name, clean up:

public partial class DropNameColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            @"DROP TRIGGER IF EXISTS trg_sync_product_title
              ON ""Products""");

        migrationBuilder.DropColumn(
            name: "Name",
            table: "Products");
    }
}

Three deployments, zero downtime.

Non-Nullable Column Strategy

Adding a required column to an existing table breaks if there's existing data. The safe approach:

// Step 1: Add as nullable with a default
migrationBuilder.AddColumn<string>(
    name: "Region",
    table: "Customers",
    nullable: true,
    defaultValue: "US");

// Step 2: Backfill existing rows
migrationBuilder.Sql(
    @"UPDATE ""Customers"" SET ""Region"" = 'US' WHERE ""Region"" IS NULL");

// Step 3: In a LATER migration, make it required
migrationBuilder.AlterColumn<string>(
    name: "Region",
    table: "Customers",
    nullable: false,
    defaultValue: "US");

Split steps 1-2 and step 3 into separate deployments. The application code should handle null values during the transition period.

Index Creation Without Blocking Writes

On large tables, a regular PostgreSQL CREATE INDEX allows reads but blocks writes on the target table. PostgreSQL also supports concurrent index creation:

public partial class AddOrderStatusIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            @"CREATE INDEX CONCURRENTLY ""IX_Orders_Status""
              ON ""Orders"" (""Status"");",
            suppressTransaction: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            @"DROP INDEX CONCURRENTLY IF EXISTS ""IX_Orders_Status"";",
            suppressTransaction: true);
    }
}

CREATE INDEX CONCURRENTLY builds the index without blocking writes. The concurrent create and drop commands both require suppressTransaction: true because PostgreSQL refuses to run either one inside a transaction block. The deployment runner must also execute the generated script as written instead of wrapping the entire file in its own transaction. The build still performs extra scans, consumes I/O, and may wait for old transactions to finish.

Keep the concurrent build in a dedicated migration with no later schema operations. It cannot be atomic with EF's migrations-history insert, and a failed build can leave an invalid index that still consumes write overhead. Inspect and remove that invalid index before retrying rather than adding IF NOT EXISTS to the create command.

I cover more deployment strategies in EF Core migrations best practices.

Applying Migrations in Production

Don't call Database.Migrate() at startup in production. It holds a lock, runs synchronously, and can fail in ways that leave the database in a partial state.

Instead, run migrations as a separate deployment step:

# Run migrations from CI/CD pipeline
dotnet ef database update --connection "$CONNECTION_STRING"

Or use a dedicated migration runner:

// In a console app or init container (host is the built IHost)
using var scope = host.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var pending = await context.Database.GetPendingMigrationsAsync();
if (pending.Any())
{
    await context.Database.MigrateAsync();
}

Run migrations before deploying new application code. The expand phase ensures backwards compatibility, so the old code keeps working while migrations run.

Migration Checklist

Before deploying a migration to production, verify:

  • Can the old application code run against the new schema?
  • Can the new application code run against the old schema?
  • Are all new columns nullable or have defaults?
  • Are destructive changes (drops, renames) in a separate contract migration?
  • Have large table operations been tested for lock duration?

Summary

Expand the schema so old and new application versions can run together, migrate reads and writes, and contract only after the old path is gone. Backfill required data in bounded batches and treat index builds or table rewrites as operational work even when the schema change is additive. Run migrations from the deployment pipeline and verify both forward compatibility and rollback behavior before production.

Frequently Asked Questions

What is the expand-contract pattern for database migrations?

Expand-contract splits every breaking schema change into three phases: expand (add the new structure alongside the old), migrate (move data and switch code to the new structure), and contract (remove the old structure). Each phase deploys independently, so old and new code always find a compatible schema.

Should I run EF Core migrations at application startup?

Not in production. Startup migrations hold locks, race when multiple instances start at once, and can fail mid-way. Run migrations as a separate deployment step from CI/CD, a migration console app, or an init container before rolling out new application code.

How do I rename a column without downtime in EF Core?

Never use a direct rename. Add the new column and copy the data (deployment 1), switch the code to the new column while keeping both in sync (deployment 2), then drop the old column once nothing references it (deployment 3).

Are additive migrations always safe?

Mostly. Adding nullable columns and new tables does not break running code, but large index builds can block writes or consume substantial I/O. PostgreSQL CREATE INDEX CONCURRENTLY avoids blocking writes but must run outside a transaction and can leave an invalid index after failure; SQL Server online index support depends on the edition, version, and index operation.

How do I add a required column to a table with existing data?

Add it as nullable with a default value first, backfill existing rows, and only make it non-nullable in a later migration after all running code writes the column. Doing it in one step fails or blocks on existing rows.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.