Entity Framework  

Building Zero-Downtime Database Migrations with EF Core 11

Deploying a new version of an application is straightforward until a database schema change is involved. Adding a column, renaming a table, or removing a field can break running applications if the database and application versions become temporarily incompatible.

Zero-downtime database migrations solve this problem by allowing old and new versions of an application to run simultaneously during deployment. Instead of applying breaking schema changes in one step, you gradually evolve the database while maintaining backward compatibility.

In this article, you'll learn how to implement production-ready zero-downtime migrations with EF Core 11 using the Expand-Contract migration strategy.

Note: This article focuses on migration strategies and implementation. Production deployment timings, workload testing, and rollback validation should be performed in your own environment.

Why Traditional Migrations Cause Downtime

Many deployments follow this sequence:

  1. Stop the application.

  2. Apply database migration.

  3. Deploy the new application.

  4. Restart services.

Although simple, this approach introduces downtime and increases deployment risk.

Consider a migration that renames a column:

EXEC sp_rename 'Customers.FullName', 'CustomerName', 'COLUMN';

If the old application is still querying FullName, requests will immediately fail after the migration.

In distributed systems, multiple application instances may also be running different versions during a rolling deployment, making direct schema changes even more risky.

What Is the Expand-Contract Pattern?

The Expand-Contract pattern avoids breaking changes by splitting schema evolution into multiple deployments.

Phase 1: Expand

Add new database objects without removing existing ones.

Examples:

  • Add new columns

  • Add new tables

  • Add new indexes

  • Add nullable fields

Both application versions continue to work.

Phase 2: Migrate

Update the application to write data to both the old and new schema if necessary, and migrate existing records.

Phase 3: Contract

After confirming all application instances use the new schema, remove obsolete columns or tables.

This staged approach enables rolling deployments without interrupting users.

Project Setup

Create a Web API project.

dotnet new webapi -n ZeroDowntimeMigrationDemo

Install EF Core packages.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design

Configure the database context.

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection")));

Initial Entity

Suppose the application initially stores a customer's full name.

public class Customer
{
    public int Id { get; set; }

    public string FullName { get; set; } = string.Empty;
}

Generate the first migration.

dotnet ef migrations add InitialCreate
dotnet ef database update

Step 1: Expand the Schema

Instead of renaming the existing column, introduce a new one.

public class Customer
{
    public int Id { get; set; }

    public string FullName { get; set; } = string.Empty;

    public string CustomerName { get; set; } = string.Empty;
}

Create a migration.

dotnet ef migrations add AddCustomerName

The generated migration resembles:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<string>(
        name: "CustomerName",
        table: "Customers",
        nullable: true);
}

This change is backward compatible because the original column still exists.

Step 2: Migrate Existing Data

Existing rows must populate the new column.

Use SQL inside the migration.

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<string>(
        name: "CustomerName",
        table: "Customers",
        nullable: true);

    migrationBuilder.Sql("""
        UPDATE Customers
        SET CustomerName = FullName
        WHERE CustomerName IS NULL
    """);
}

Performing the update during migration keeps historical data consistent.

Step 3: Support Both Columns

During deployment, some application instances may still read FullName while newer instances use CustomerName.

A temporary mapping keeps both synchronized.

public async Task UpdateCustomerAsync(Customer customer)
{
    customer.CustomerName = customer.FullName;

    _context.Update(customer);

    await _context.SaveChangesAsync();
}

If business requirements allow, write to both columns until every application instance has been upgraded.

Step 4: Deploy the Updated Application

Deploy the new application version after the schema expansion.

At this stage:

  • Old application works.

  • New application works.

  • Database remains compatible with both versions.

This makes rolling deployments significantly safer.

Step 5: Contract the Schema

After confirming that no application version depends on FullName, remove it.

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropColumn(
        name: "FullName",
        table: "Customers");
}

This should only occur after verifying all production instances have been upgraded.

End-to-End Deployment Workflow

A safe production deployment typically follows these steps:

  1. Create an additive migration.

  2. Apply the migration.

  3. Migrate existing data.

  4. Deploy the new application.

  5. Monitor logs and health checks.

  6. Verify all instances are running the new version.

  7. Remove obsolete schema in a later deployment.

This minimizes deployment risk and avoids application downtime.

Schema Change Comparison

Schema ChangeZero-Downtime SafeRecommendation
Add nullable columnYesPreferred
Add new tableYesSafe
Create indexYesSafe
Rename columnNoUse Expand-Contract
Remove columnNoDelay until final phase
Change data typeDependsUse staged migration
Rename tableNoCreate new table and migrate

Production Considerations

Avoid Automatic Migrations at Startup

Avoid running:

context.Database.Migrate();

inside application startup for production systems.

Instead, apply migrations through a deployment pipeline or release process. This provides better control, auditing, and rollback capabilities.

Keep Migrations Small

Large migrations that update millions of rows can lock tables and impact application performance.

Prefer multiple smaller migrations over one large migration.

Backup Before Deployment

Always create a verified database backup before applying schema changes.

Backups remain the fastest recovery option if unexpected issues occur.

Rollback Strategy

Application rollbacks are easier when the schema remains backward compatible.

A recommended deployment order is:

  1. Expand schema.

  2. Deploy application.

  3. Validate production.

  4. Contract schema later.

Avoid removing schema objects immediately after deployment.

Best Practices

  • Use additive schema changes whenever possible.

  • Separate schema expansion from cleanup.

  • Test migrations against production-sized databases.

  • Keep migration scripts under source control.

  • Monitor long-running migrations.

  • Review generated SQL before deployment.

  • Coordinate migrations with CI/CD pipelines.

  • Validate application compatibility before removing legacy schema.

Common Mistakes

MistakeImpact
Renaming columns directlyBreaks running applications
Dropping columns immediatelyPrevents rollback
Running large updates during peak hoursIncreased lock contention
Combining multiple risky changesDifficult troubleshooting
Skipping migration testingProduction failures
Automatic startup migrationsUncontrolled deployments

Troubleshooting

Migration Takes Too Long

Possible causes:

  • Large table updates

  • Missing indexes

  • Blocking transactions

  • Resource constraints

Consider batching updates instead of modifying every row in a single transaction.

Deployment Fails After Migration

Verify:

  • Connection strings

  • Migration order

  • Database permissions

  • Generated SQL

  • Application version compatibility

Old Application Stops Working

This usually indicates a breaking schema change was introduced too early.

Review whether columns, tables, or constraints were removed before all application instances were upgraded.

FAQs

What is a zero-downtime migration?

A deployment strategy that allows database schema changes while the application continues serving requests without interruption.

Why shouldn't I rename columns directly?

During rolling deployments, older application instances may still reference the original column name, resulting in runtime failures.

Is Expand-Contract only for EF Core?

No. The pattern is database-agnostic and applies to most relational databases and ORM frameworks.

Can I use Database.Migrate() in production?

It is generally better to execute migrations through controlled deployment pipelines rather than automatically during application startup.

Should every migration follow Expand-Contract?

Not necessarily. Simple additive changes, such as creating a new table or adding a nullable column, are already compatible. The pattern is most valuable for potentially breaking schema changes.

Conclusion

Zero-downtime database migrations are essential for modern ASP.NET Core applications that use rolling deployments, containers, or multiple application instances. Rather than making breaking schema changes in a single deployment, the Expand-Contract pattern gradually evolves the database while maintaining compatibility with both old and new application versions.

By favoring additive migrations, separating schema cleanup into later releases, and validating changes through controlled deployment pipelines, you can reduce deployment risk, simplify rollbacks, and keep applications available throughout the release process.