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:

  1. Identify the exact DDL operation.

  2. Check whether existing rows must be modified.

  3. Check the expected lock behavior.

  4. Review indexes and constraints affected by the change.

  5. Test against production-sized data.

  6. Use expand-and-contract when application compatibility is required.

  7. Batch large data backfills.

  8. Use online index operations when supported and appropriate.

  9. Consider WAIT_AT_LOW_PRIORITY for online index operations.

  10. Consider resumable operations for long-running index or constraint work.

  11. Monitor transaction log growth.

  12. Define a rollback or forward-fix strategy.

  13. 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.