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:
Stop the application.
Apply database migration.
Deploy the new application.
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:
Create an additive migration.
Apply the migration.
Migrate existing data.
Deploy the new application.
Monitor logs and health checks.
Verify all instances are running the new version.
Remove obsolete schema in a later deployment.
This minimizes deployment risk and avoids application downtime.
Join the conversation! Your thoughts help the community grow.