powershelldba.de

ALTER COLUMN on Large Tables: Avoiding Transaction Log Explosion

Run a plain ALTER TABLE ... ALTER COLUMN against a 500-million-row table and SQL Server may rewrite every single row inside one transaction. The log grows until it either fills the disk or stalls the operation. Here's how to change a column's type, length, or nullability without taking down the log, or the table.

Why ALTER COLUMN Is Not "Just a Metadata Change"

Most DBAs expect DDL to be fast, because most DDL is fast: adding a column with a default, creating a check constraint WITH NOCHECK, renaming a table. ALTER COLUMN breaks that expectation for one simple reason: in many cases it is a size-of-data operation. SQL Server has to touch every row to verify or rewrite it, and every row it touches gets logged, inside a single, uninterruptible transaction.

-- 500 million rows, changing a VARCHAR from 20 to 15 characters
ALTER TABLE dbo.CustomerOrders
ALTER COLUMN OrderRef VARCHAR(15) NOT NULL;

-- ✗ Full table scan to validate every value fits and is non-NULL
-- ✗ Every row rewritten and logged in ONE transaction
-- ✗ Table is SCH-M locked for the entire duration: no reads, no writes
-- ✗ Transaction log grows to roughly the size of the table (or more)
-- ✗ If the log runs out of space, the operation fails and rolls back
--   the ENTIRE rewrite, which can take longer than the ALTER itself

When It's Cheap vs. When It's Expensive

Not every ALTER COLUMN is a disaster. SQL Server (2016+) optimizes a specific case: increasing the length of a variable-length type (VARCHAR, NVARCHAR, VARBINARY) with no other change. That's metadata-only: no rewrite, no meaningful log activity.

Change Cost Why
VARCHAR(20) → VARCHAR(50) Metadata-only Larger container, no existing value can violate it
VARCHAR(50) → VARCHAR(20) Full scan Every value must be checked against the new, smaller limit
INT → BIGINT Full rewrite Different storage size: every row physically changes
NULL → NOT NULL Full scan Every row checked for existing NULLs
DECIMAL(10,2) → DECIMAL(12,4) Full rewrite Precision/scale change alters the stored bytes
⚠ Indexes make it worse. If the column is part of any index, the index has to be rebuilt as well, doubling (or more) the logged work. Drop non-critical indexes on the column before altering it, and rebuild them afterward.

Strategy 1: Batch Rebuild via Shadow Column

The Concept

Instead of asking SQL Server to rewrite 500 million rows in one transaction, do it yourself in small, committed batches. Add the new column, populate it in chunks, then swap it in.

-- 1. Add the new column (fast if nullable, no default)
ALTER TABLE dbo.CustomerOrders
ADD OrderRef_New VARCHAR(15) NULL;

-- 2. Populate in batches, committing after each one
DECLARE @BatchSize INT = 20000;
DECLARE @RowsUpdated INT = 1;

WHILE @RowsUpdated > 0
BEGIN
  UPDATE TOP (@BatchSize) dbo.CustomerOrders
  SET OrderRef_New = LEFT(OrderRef, 15)
  WHERE OrderRef_New IS NULL;

  SET @RowsUpdated = @@ROWCOUNT;
  -- COMMIT here if wrapped in an explicit transaction per batch
  WAITFOR DELAY '00:00:00.250';  -- let other sessions in between batches
END;

-- 3. Swap columns (fast, metadata operations)
ALTER TABLE dbo.CustomerOrders DROP COLUMN OrderRef;
EXEC sp_rename 'dbo.CustomerOrders.OrderRef_New', 'OrderRef', 'COLUMN';
ALTER TABLE dbo.CustomerOrders ALTER COLUMN OrderRef VARCHAR(15) NOT NULL;
-- (this final ALTER still scans, but only to enforce NOT NULL:
--  much cheaper once the data is already correctly shaped)
Why this works:
• Each batch is a small, fast transaction; the log can be reused between them
• The table stays readable and writable between batches
• A failure mid-run loses only the current batch, not the whole operation
• You can pause, resume, or throttle the batch loop under production load

Strategy 2: Manage the Log Instead of Fighting It

Recovery Model and Log Backups

Batching only helps the log if something actually truncates it between batches. Under the FULL recovery model, that means log backups: not just checkpoints.

-- During a long batch run, keep log backups frequent
BACKUP LOG [YourDatabase] TO DISK = N'\\backupshare\YourDatabase_log.trn';

-- Schedule this every 1-2 minutes for the duration of the batch job,
-- or trigger it from the same script after every N batches.
-- Without this, FULL recovery still accumulates log records
-- across "committed" batches; they're just individually small.

Under SIMPLE recovery, log space is reclaimed automatically after each checkpoint following a committed batch; no manual log backup needed, but you lose point-in-time recovery for the duration.

Pre-Size the Log: Don't Let Autogrowth Do It

Every autogrowth event stalls the operation while the file extends, and default percentage-based growth produces hundreds of small VLFs, which slows crash recovery and log backups for a long time afterward.

-- Grow the log to a realistic target BEFORE starting the batch job
ALTER DATABASE [YourDatabase]
MODIFY FILE (NAME = YourDatabase_log, SIZE = 20GB);

-- Set fixed-size autogrowth as a safety net, not the primary mechanism
ALTER DATABASE [YourDatabase]
MODIFY FILE (NAME = YourDatabase_log, FILEGROWTH = 1024MB);

-- Check current VLF count and log usage
DBCC LOGINFO;
SELECT total_log_size_in_bytes, used_log_space_in_bytes
FROM sys.dm_db_log_space_usage;
⚠ Don't shrink the log immediately after. If you'll need similar headroom again soon (more batches, an index rebuild), shrinking and re-growing the log just recreates the VLF fragmentation problem you fixed.

Strategy 3: Rebuild via New Table (for the biggest tables)

The Concept

For tables in the hundreds of millions of rows, or when a maintenance window is available, it's often faster and safer to build a new table with the target schema, bulk-copy the data, and swap it in, rather than altering in place.

-- 1. Create the target table with the correct schema up front
CREATE TABLE dbo.CustomerOrders_New (
  OrderID       BIGINT       NOT NULL,
  OrderRef      VARCHAR(15)  NOT NULL,
  CustomerID    INT          NOT NULL,
  OrderDate     DATETIME2    NOT NULL
);

-- 2. Bulk copy in batches: minimally logged if recovery model
--    is SIMPLE/BULK_LOGGED and the target has no indexes yet
INSERT INTO dbo.CustomerOrders_New WITH (TABLOCK)
SELECT OrderID, LEFT(OrderRef, 15), CustomerID, OrderDate
FROM dbo.CustomerOrders
WHERE OrderID BETWEEN 1 AND 10000000;
-- repeat in ID ranges, or drive with a loop / SSIS / batch script

-- 3. Build indexes and constraints on the new table

-- 4. Swap (requires a short exclusive lock, but it's fast)
EXEC sp_rename 'dbo.CustomerOrders', 'CustomerOrders_Old';
EXEC sp_rename 'dbo.CustomerOrders_New', 'CustomerOrders';

If the table is partitioned, ALTER TABLE ... SWITCH PARTITION is even better: it's a pure metadata operation with no data movement or logging of row-level changes at all; build the reshaped data in a staging table per partition, then switch.

Comparison of Approaches

Approach Log Impact Downtime Best For
Direct ALTER COLUMN Very high (whole table, one transaction) Table locked throughout Small/medium tables only
Shadow column + batch UPDATE Low per batch, reclaimable between batches None (brief locks per batch) Production tables, 24/7 availability
New table + bulk copy Low (minimal logging possible) Short window during swap Very large tables, maintenance windows
SWITCH PARTITION None (metadata only) None Partitioned tables

Monitoring While It Runs

-- Watch log usage live during the operation
SELECT
  name,
  size / 128.0 AS size_mb,
  FILEPROPERTY(name, 'SpaceUsed') / 128.0 AS used_mb
FROM sys.database_files
WHERE type_desc = 'LOG';

-- Watch for lock escalation and blocking during batches
SELECT
  request_session_id,
  resource_type,
  request_mode,
  request_status
FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID('YourDatabase')
ORDER BY request_session_id;

Best Practices Checklist

The Bottom Line

ALTER COLUMN looks like a one-line DDL statement, but on a large table it behaves like a full-table UPDATE wrapped in a single transaction, with all the log growth and locking that implies. Test whether your specific change is metadata-only first. If it isn't, don't run it directly against a huge table: break the rewrite into small, committed batches with a shadow column, keep the log reclaimable as you go, and reserve the new-table-and-swap approach for the tables where even batching is too slow.

← Back to Blog