A database schema change can take down a .NET application even when the application code itself is correct.
The problem is usually not the ALTER TABLE statement. It is the interaction between schema locks, long-running transactions, existing application code, indexes, constraints, and the order in which a new application version is deployed.
For example, this looks harmless:
ALTER TABLE dbo.Customers
ADD IsVerified bit NOT NULL DEFAULT 0;
On a busy production database, the same statement can have very different operational consequences depending on the table, SQL Server version, existing workload, and the exact definition of the change.
A safer approach is to treat database migrations as a deployment process rather than a collection of SQL statements.
Why Schema Changes Can Cause Downtime
SQL Server uses schema locks to protect database metadata while DDL operations are running.
An ALTER TABLE operation can require a schema modification lock (Sch-M). If another transaction is holding a conflicting lock, the schema change can wait.
The reverse can also happen. A schema change waiting for a lock can eventually block application requests.
The result can look like this:
Application requests
|
v
Long-running transaction
|
v
Schema migration waits
|
v
New requests begin waiting
|
v
Application latency increases
This is why a migration that executes successfully in a development environment can still create production problems.
The first question should therefore be:
Does this migration need to touch existing rows, rebuild an index, validate existing data, or acquire a long-lived schema lock?
The Expand-and-Contract Pattern
One of the safest patterns for .NET applications is expand and contract.
Instead of changing the database and application at the same time, introduce the new schema in stages.
Phase 1: Expand
Add the new database structure while keeping the existing application functional.
For example:
ALTER TABLE dbo.Customers
ADD DisplayName nvarchar(200) NULL;
The old application does not need this column, so it can continue working.
Phase 2: Deploy Compatible Application Code
Deploy application code that can work with both the old and new schema.
For example:
public sealed class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? DisplayName { get; set; }
}
The application can initially continue using Name while the new column is populated.
Phase 3: Backfill
Populate existing rows in controlled batches instead of performing one enormous update.
UPDATE TOP (1000) dbo.Customers
SET DisplayName = Name
WHERE DisplayName IS NULL;
Repeat the operation until the required rows are populated.
Batching limits the amount of work performed by a single transaction and gives the application opportunities to continue processing between batches.
Phase 4: Switch Application Behavior
Once the new column contains the required data, deploy code that uses DisplayName.
Phase 5: Contract
Only after the application no longer depends on the old schema should you remove the old column or constraint.
ALTER TABLE dbo.Customers
DROP COLUMN Name;
The exact timing depends on whether older application instances can still be running.
Why Backward Compatibility Matters
Consider a deployment where five application instances are running.
If the database migration removes a column immediately, an old application instance may still execute:
SELECT Id, Name
FROM dbo.Customers;
If Name has already been removed, that instance fails.
A safer deployment sequence is:
Old application
|
v
Add new schema
|
v
Deploy compatible application
|
v
Backfill data
|
v
Switch application
|
v
Remove old schema later
This approach also makes rollbacks easier because the previous application version can continue working with the expanded schema.
Not Every ALTER TABLE Is Equally Expensive
A common mistake is to classify every ALTER TABLE as either "safe" or "unsafe."
The actual behavior depends on the operation.
For example, SQL Server can add certain columns with metadata-only behavior when the default can be represented appropriately. In other cases, the operation needs to modify existing rows.
Consider:
ALTER TABLE dbo.Orders
ADD IsArchived bit NOT NULL
CONSTRAINT DF_Orders_IsArchived DEFAULT 0;
This may be handled efficiently by SQL Server in supported scenarios, but you should not assume every ADD COLUMN operation is metadata-only.
The column type, default expression, row-size limits, existing objects, SQL Server version, and table design can affect the operation.
For production migrations, test the exact statement against a representative database.
Adding a NOT NULL Column Safely
Adding a nullable column is usually easier:
ALTER TABLE dbo.Customers
ADD PreferredLanguage nvarchar(20) NULL;
The application can begin writing the new value immediately.
If the final requirement is:
PreferredLanguage must never be NULL
do not necessarily enforce that constraint during the first deployment.
A safer sequence is:
1. Add nullable column
2. Deploy application support
3. Populate existing rows
4. Verify no NULL values remain
5. Add NOT NULL constraint
Before enforcing the constraint, check the data:
SELECT COUNT(*) AS MissingValues
FROM dbo.Customers
WHERE PreferredLanguage IS NULL;
Only proceed when the result is zero.
Backfill Large Tables in Batches
Large data migrations can create their own availability problems.
Avoid:
UPDATE dbo.Customers
SET DisplayName = Name
WHERE DisplayName IS NULL;
on a very large production table without understanding the transaction size and workload.
A batch-oriented approach is easier to control:
WHILE 1 = 1
BEGIN
UPDATE TOP (1000)
dbo.Customers
SET DisplayName = Name
WHERE DisplayName IS NULL;
IF @@ROWCOUNT = 0
BREAK;
WAITFOR DELAY '00:00:01';
END;
The batch size should be chosen based on the workload.
There is no universal value such as 1,000 or 10,000 that is correct for every production system.
Monitor:
transaction log growth
lock duration
CPU
I/O
query latency
replication or change capture lag
application error rates
The goal is controlled progress, not simply finishing the migration as quickly as possible.
Use Online Index Operations When Supported
Index creation and rebuilding can be expensive on large tables.
SQL Server supports online index operations in supported editions and scenarios:
CREATE INDEX IX_Customers_Email
ON dbo.Customers (Email)
WITH (ONLINE = ON);
An online operation allows normal queries and modifications to continue during most of the operation, but it does not mean the operation requires zero locking.
The beginning and final phases can still require locks, including a short Sch-M lock depending on the operation.
That final lock can matter on a busy system.
Control Blocking with WAIT_AT_LOW_PRIORITY
For supported online index operations, WAIT_AT_LOW_PRIORITY lets you control what happens when the operation cannot immediately obtain the required lock.
For example:
CREATE INDEX IX_Customers_Email
ON dbo.Customers (Email)
WITH
(
ONLINE = ON
(
WAIT_AT_LOW_PRIORITY
(
MAX_DURATION = 5 MINUTES,
ABORT_AFTER_WAIT = SELF
)
)
);
Here, the index operation waits at low priority for up to five minutes.
If the required lock still cannot be acquired, the operation aborts itself.
This is often safer than allowing a migration to wait indefinitely and unexpectedly affect application traffic.
Be careful with:
ABORT_AFTER_WAIT = BLOCKERS
That option can terminate user transactions blocking the operation and requires appropriate permissions.
Killing application transactions should be an explicit operational decision, not a default migration behavior.
Use Resumable Operations for Long Index Work
A large index operation may take longer than the maintenance window available to your application.
Supported SQL Server versions provide resumable index operations.
For example:
CREATE INDEX IX_Orders_CreatedAt
ON dbo.Orders (CreatedAt)
WITH
(
ONLINE = ON,
RESUMABLE = ON,
MAX_DURATION = 60
);
A resumable operation can be paused and resumed:
ALTER INDEX IX_Orders_CreatedAt
ON dbo.Orders
PAUSE;
Then:
ALTER INDEX IX_Orders_CreatedAt
ON dbo.Orders
RESUME;
This can be useful when an operation needs to fit around controlled maintenance periods.
Resumable operations also provide recovery options for certain interruptions instead of requiring the entire operation to start again.
Adding Constraints Requires Planning
Constraints can expose bad data that already exists.
Suppose the application wants:
ALTER TABLE dbo.Customers
ADD CONSTRAINT UQ_Customers_Email
UNIQUE (Email);
Before running it, find duplicates:
SELECT Email, COUNT(*) AS DuplicateCount
FROM dbo.Customers
GROUP BY Email
HAVING COUNT(*) > 1;
If duplicates exist, the migration will not solve the data problem.
The application team needs a data-cleaning strategy first.
For supported SQL Server scenarios, adding primary key or unique constraints can also use resumable operations. This can be useful for large tables where building the supporting structure is expensive.
Handle Application and Database Deployment Together
A .NET application should not assume that a migration runs instantaneously.
For example, imagine introducing:
public bool IsArchived { get; set; }
The database migration must add the corresponding column before application instances start writing to it.
A deployment pipeline can use:
Build
|
v
Run compatibility migration
|
v
Deploy application
|
v
Backfill data
|
v
Validate
|
v
Enable new behavior
|
v
Remove legacy schema later
This is safer than:
Drop old column
|
v
Deploy new application
The second approach creates a period where the running application and database disagree about the schema.
EF Core Migrations Need the Same Discipline
Entity Framework Core makes schema changes easier to generate, but it does not automatically make every migration safe for production.
A generated migration might contain:
migrationBuilder.AddColumn<string>(
name: "DisplayName",
table: "Customers",
type: "nvarchar(200)",
nullable: true);
The code is valid, but production safety still depends on the actual database and workload.
For large tables, inspect the generated SQL before deployment.
You may need to split a logical change into multiple migrations:
Migration 1:
Add nullable column
Migration 2:
Application starts writing the column
Migration 3:
Backfill existing records
Migration 4:
Enforce NOT NULL or other constraints
Migration 5:
Remove legacy column
This takes more planning but gives the deployment process clear safety boundaries.
Test Migrations Against Production-Like Data
A migration test database containing 100 rows does not tell you how a migration behaves against a table containing hundreds of millions of rows.
A useful test environment should approximate:
table size
indexes
constraints
active connections
representative queries
transaction behavior
SQL Server configuration
database compatibility level
Measure the migration itself.
For example:
Migration duration
Lock wait duration
Transaction log growth
CPU usage
I/O usage
Application latency
Failed requests
Do not rely only on whether the migration completed successfully.
Common Mistakes
Changing and Removing in One Deployment
Dropping a column while deploying code that still references it creates an unnecessary failure window.
Assuming ONLINE Means No Blocking
Online operations reduce the duration of major blocking, but short lock acquisition phases can still affect production traffic.
Running a Huge Backfill in One Transaction
Large updates can increase log usage, locking, and recovery time.
Adding Constraints Without Checking Data
Existing duplicates or invalid values can cause the migration to fail.
Testing Only on Small Databases
Migration behavior can change significantly with production-sized data.
Automatically Killing Blockers
Using ABORT_AFTER_WAIT = BLOCKERS without understanding the workload can terminate legitimate transactions.
Treating EF Core Migrations as Risk-Free
EF Core generates migration code. It does not know your production traffic pattern or operational risk automatically.
Troubleshooting a Blocked Migration
When a migration is waiting, determine what it is waiting for before changing the SQL.
Check:
1. Which session is executing the migration?
2. Which session is blocking it?
3. Which object is locked?
4. How long has the blocking transaction been running?
5. Is the migration waiting for Sch-M?
6. Is application traffic increasing the blocking?
For index operations using low-priority waits, monitor the operation and its lock state rather than assuming it has failed.
If the migration repeatedly encounters blockers, the answer may be operational scheduling rather than a different SQL statement.
A Safer Migration Checklist
Before deploying a production schema change:
Identify the exact DDL operation.
Check whether existing rows must be modified.
Check the expected lock behavior.
Review indexes and constraints affected by the change.
Test against production-sized data.
Use expand-and-contract when application compatibility is required.
Batch large data backfills.
Use online index operations when supported and appropriate.
Consider
WAIT_AT_LOW_PRIORITYfor online index operations.Consider resumable operations for long-running index or constraint work.
Monitor transaction log growth.
Define a rollback or forward-fix strategy.
Delay destructive cleanup until old application versions are no longer running.
Advantages and Disadvantages
Advantages | Disadvantages |
|---|---|
Reduces the risk of application downtime | Requires multiple deployment stages |
Supports rolling application deployments | Temporary schema objects may remain longer |
Makes rollback easier | Backfills add operational work |
Gives better control over locks | Large migrations still consume resources |
Online and resumable operations can reduce maintenance impact | Feature availability depends on SQL Server version and operation |
Works well with .NET and EF Core deployments | Requires testing against production-like data |
A Practical Deployment Strategy
For a typical .NET application, the safest approach is to make schema changes compatible with more than one application version.
For example:
Release 1
|
+--> Add nullable column
|
v
Release 2
|
+--> Application writes new column
|
v
Backfill
|
+--> Existing records updated in batches
|
v
Release 3
|
+--> Application reads new column
|
v
Release 4
|
+--> Remove old column
The key idea is simple: do not make the database and application change their contract at the same moment when you can avoid it.
For .NET applications running against SQL Server, zero-downtime schema deployment is less about finding one magical ALTER TABLE statement and more about controlling compatibility, locks, data movement, and deployment order.

Join the conversation! Your thoughts help the community grow.