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:
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.
Schema Change Comparison
| Schema Change | Zero-Downtime Safe | Recommendation |
|---|
| Add nullable column | Yes | Preferred |
| Add new table | Yes | Safe |
| Create index | Yes | Safe |
| Rename column | No | Use Expand-Contract |
| Remove column | No | Delay until final phase |
| Change data type | Depends | Use staged migration |
| Rename table | No | Create 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:
Expand schema.
Deploy application.
Validate production.
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
| Mistake | Impact |
|---|
| Renaming columns directly | Breaks running applications |
| Dropping columns immediately | Prevents rollback |
| Running large updates during peak hours | Increased lock contention |
| Combining multiple risky changes | Difficult troubleshooting |
| Skipping migration testing | Production failures |
| Automatic startup migrations | Uncontrolled 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:
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.