SQL Server Transaction Log Internals: WAL, VLFs and Checkpoints (2026)

SQL Server Transaction Log Internals

The transaction log is the single most important file in a SQL Server database. It is the source of ACID durability, the mechanism for crash recovery, the feed for log shipping and Always On Availability Groups, and the reason why a COMMIT cannot return until the relevant log records hit disk. Knowing how it works internally lets you size it correctly, avoid VLF fragmentation, diagnose log-full emergencies, and eliminate WRITELOG wait times.

Write-Ahead Logging: The Core Guarantee

SQL Server uses Write-Ahead Logging (WAL): before any data page modification is written to disk, the corresponding log records describing that modification must be written to the log file first. This invariant is what makes crash recovery possible.

The flow of a single UPDATE statement:

  1. SQL Server reads the target data page into the buffer pool (if not already there)
  2. It writes log records describing the change (before-image, after-image, transaction ID, LSN) to the log buffer in memory
  3. It modifies the data page in the buffer pool (the page is now "dirty")
  4. On COMMIT, the log buffer is flushed to the .ldf file — this is the WRITELOG wait
  5. Once the log flush completes, COMMIT returns to the caller
  6. The dirty data page is written to the .mdf/.ndf files later — lazily, by the checkpoint process
Why WAL enables recovery: If the server crashes after step 4 but before step 6, the committed changes are in the log. On restart, SQL Server reads the log (REDO phase) and re-applies the changes to the data files. Uncommitted transactions found in the log are rolled back (UNDO phase). The result: no committed transaction is ever lost.

Log Records and Log Sequence Numbers

Every change to the database produces one or more log records. Each log record contains:

  • LSN (Log Sequence Number) — a globally unique, monotonically increasing identifier: VLF sequence : log block : record within block
  • Transaction ID — which transaction produced this record
  • Operation type — INSERT, UPDATE, DELETE, page allocation, index split, etc.
  • Before image — the original value (for rollback)
  • After image — the new value (for redo after a crash)
  • Previous LSN for this transaction — forms a backward-linked chain per transaction for fast rollback
-- Read the active portion of the transaction log (use sparingly)
SELECT TOP 100
    [Current LSN],
    Operation,
    Context,
    [Transaction ID],
    [Log Record Length],
    AllocUnitName,
    [Begin Time]
FROM fn_dblog(NULL, NULL)
ORDER BY [Current LSN];
fn_dblog is not a DMV — it reads and parses the active log on the fly, which is expensive. Use only for diagnostics on non-production databases or with tight LSN range limits.

Virtual Log Files (VLFs)

A physical log file (.ldf) is divided internally into Virtual Log Files (VLFs). VLFs are the unit of log truncation — SQL Server cannot reuse space within a VLF until the entire VLF is no longer needed (all transactions in it are committed and checkpointed, or backed up in Full recovery).

SQL Server creates VLFs automatically when the log file is first created or grows. The number of VLFs created per growth event depends on the growth size:

Growth AmountVLFs Created
< 64 MB4
64 MB – 1 GB8
> 1 GB16
-- Check VLF count and status
DBCC LOGINFO;
-- OR (SQL Server 2016+):
SELECT database_id, vlf_sequence_number, vlf_active, vlf_status,
       vlf_size_mb, vlf_begin_lsn, vlf_create_lsn
FROM sys.dm_db_log_info(DB_ID())
ORDER BY vlf_sequence_number;

vlf_status: 0 = inactive (reusable), 2 = active (contains needed log records).

VLF Fragmentation and Performance Impact

A log file that has grown many times via small autogrowth events can accumulate thousands of VLFs. This causes:

  • Slow database startup — SQL Server scans all VLFs during crash recovery and log initialization
  • Slow log backup and restore — backup must read and catalogue every VLF header
  • Slow log truncation — the engine checks each VLF status during truncation
-- Count VLFs per database (> 50 is concerning; > 1000 is a problem)
SELECT DB_NAME(database_id) AS db_name, COUNT(*) AS vlf_count
FROM sys.dm_db_log_info(NULL)  -- NULL = all databases
GROUP BY database_id
ORDER BY vlf_count DESC;

To fix VLF fragmentation, shrink the log then pre-size it with a single large growth event:

USE YourDB;

-- Step 1: Back up the log to free inactive VLFs
BACKUP LOG YourDB TO DISK = 'NUL';  -- dev/test only; use real backup in production

-- Step 2: Shrink the log file to minimum (removes empty VLFs)
DBCC SHRINKFILE (YourDB_log, 1);

-- Step 3: Pre-size to desired final size in ONE growth event
-- (creates 16 VLFs for a 10 GB file)
ALTER DATABASE YourDB
MODIFY FILE (NAME = YourDB_log, SIZE = 10240MB, FILEGROWTH = 1024MB);
Avoid frequent small autogrowth. The default autogrowth of 10% on a 500 MB log creates 50 MB at a time (4 VLFs per event). After 100 growth events you have 400 VLFs. Pre-size the log to expected peak usage and set autogrowth to a fixed large value (e.g., 1 GB) as a safety net only.

The Checkpoint Process

SQL Server's checkpoint process writes all dirty data pages from the buffer pool to the data files, then writes a checkpoint record to the log. After a checkpoint, SQL Server knows that all changes up to that LSN are safely on disk in the data files — so on crash recovery, REDO only needs to start from the oldest active transaction, not the beginning of the log.

Checkpoint types:

  • Automatic checkpoint — triggered when the estimated crash-recovery time would exceed the recovery interval setting (default: 0 = ~1 minute)
  • Indirect checkpoint (SQL 2012+, default for new databases) — targets a fixed recovery time (TARGET_RECOVERY_TIME per database), flushing dirty pages more smoothly
  • Manual checkpointCHECKPOINT [checkpoint_duration]
  • Internal checkpoint — triggered by log backup, database snapshot, or ALTER DATABASE
-- Check checkpoint settings
SELECT name, target_recovery_time_in_seconds, recovery_model_desc
FROM sys.databases
WHERE name = DB_NAME();

-- Enable indirect checkpoint (recommended; smooths I/O)
ALTER DATABASE YourDB SET TARGET_RECOVERY_TIME = 60 SECONDS;
Indirect checkpoint vs automatic checkpoint: Automatic checkpoint can create I/O spikes — it flushes all dirty pages at once. Indirect checkpoint flushes pages continuously in the background, producing smoother I/O and more predictable recovery times. SQL Server 2016+ uses indirect checkpoint by default for all new databases.

Log Truncation vs Log Shrink

Log truncation marks inactive VLFs as reusable. Space is not returned to the OS — it stays within the .ldf file for SQL Server to reuse. Truncation happens automatically after:

  • A checkpoint (in Simple recovery model)
  • A log backup (in Full or Bulk-Logged recovery model)

Log shrink returns physical file space to the OS. It should be done rarely and only after confirming the log is genuinely oversized:

-- Shrink after truncation (after log backup or checkpoint)
DBCC SHRINKFILE (YourDB_log, 512);  -- target 512 MB
Do not routinely shrink the log. Shrinking and then letting it grow again creates VLF fragmentation and I/O spikes during growth. Pre-size the log to its steady-state size and leave it there.

log_reuse_wait_desc: Why the Log Won't Truncate

When the log cannot be truncated, sys.databases.log_reuse_wait_desc tells you why:

SELECT name, log_reuse_wait_desc, recovery_model_desc,
       log_size_mb = (SELECT SUM(size)*8/1024 FROM sys.master_files
                      WHERE database_id = d.database_id AND type=1)
FROM sys.databases d
WHERE name = DB_NAME();
log_reuse_wait_descMeaningFix
NOTHINGLog has been truncated; space availableN/A
CHECKPOINTWaiting for the next checkpointRun CHECKPOINT manually
LOG_BACKUPFull recovery: waiting for a log backupRun a log backup
ACTIVE_TRANSACTIONLong-running open transactionFind and commit/rollback the transaction
DATABASE_MIRRORINGLog shipping/mirroring hasn't consumed recordsCheck mirroring or shipping status
REPLICATIONLog reader agent hasn't read the recordsCheck replication log reader agent
AVAILABILITY_REPLICAAlways On AG secondary hasn't applied logCheck AG health and secondary redo queue
ACTIVE_BACKUP_OR_RESTOREBackup or restore in progressWait for it to complete
-- Find the oldest active transaction blocking log truncation
SELECT
    s.session_id,
    s.login_name,
    at.transaction_begin_time,
    DATEDIFF(MINUTE, at.transaction_begin_time, GETDATE()) AS minutes_open,
    at.transaction_type,
    at.transaction_state,
    t.text AS last_sql
FROM sys.dm_tran_active_transactions at
JOIN sys.dm_tran_session_transactions st ON at.transaction_id = st.transaction_id
JOIN sys.dm_exec_sessions s ON st.session_id = s.session_id
OUTER APPLY sys.dm_exec_sql_text(s.most_recent_sql_handle) t
ORDER BY at.transaction_begin_time;

Recovery Models: Simple, Full, Bulk-Logged

ModelLog TruncationPoint-in-Time RecoveryLog Growth Risk
SimpleAfter each checkpointNo (last full/diff backup only)Low
FullAfter each log backupYesHigh if log backups are infrequent
Bulk-LoggedAfter each log backupPartial (bulk ops not point-in-time)Medium (bulk ops minimally logged)
-- Check and change recovery model
SELECT name, recovery_model_desc FROM sys.databases WHERE name = DB_NAME();
ALTER DATABASE YourDB SET RECOVERY FULL;
ALTER DATABASE YourDB SET RECOVERY SIMPLE;  -- dev/test only
Switching from Full to Simple: Immediately breaks the log backup chain. After switching back to Full, take a full database backup before resuming log backups — otherwise the log backup chain is broken and point-in-time recovery is impossible.

Log Backups in Full Recovery

In Full recovery, the log grows until a log backup is taken. Frequency determines both your RPO (recovery point objective) and maximum log size:

-- Manual log backup
BACKUP LOG YourDB
TO DISK = 'D:\Backups\YourDB_log_20260611_1400.bak'
WITH COMPRESSION, STATS = 10;

-- Check backup history and log backup frequency
SELECT
    bs.database_name,
    bs.type,                      -- 'L' = log
    bs.backup_start_date,
    bs.backup_finish_date,
    bs.backup_size / 1048576.0   AS backup_mb
FROM msdb.dbo.backupset bs
WHERE bs.database_name = 'YourDB'
  AND bs.type = 'L'
ORDER BY bs.backup_start_date DESC;

For OLTP databases, take log backups every 15–30 minutes. For high-transaction databases (hundreds of MB/hour of log), every 5 minutes may be necessary to control log size.

Log File Sizing and Pre-Growth Strategy

The ideal log size = peak log generation rate × longest interval between log backups × 1.5 safety margin.

-- Measure log generation rate (run twice, 60 seconds apart, subtract)
SELECT log_bytes_used = cntr_value
FROM sys.dm_os_performance_counters
WHERE counter_name = 'Log Bytes Flushed/sec'
  AND instance_name = 'YourDB';

-- Or check log usage trend from backup history
SELECT
    backup_start_date,
    backup_size / 1048576.0  AS log_backup_mb,
    DATEDIFF(MINUTE, LAG(backup_start_date) OVER (ORDER BY backup_start_date),
             backup_start_date) AS minutes_since_last
FROM msdb.dbo.backupset
WHERE database_name = 'YourDB' AND type = 'L'
ORDER BY backup_start_date DESC;
-- Pre-size strategy: one growth event to final size, small autogrowth as safety
ALTER DATABASE YourDB
MODIFY FILE (
    NAME = YourDB_log,
    SIZE = 10240MB,        -- pre-sized to 10 GB
    FILEGROWTH = 1024MB    -- 1 GB emergency autogrowth only
);

Monitoring Log Usage

-- Current log usage percentage
SELECT
    DB_NAME(database_id) AS db_name,
    log_size_mb = cntr_value / 1024.0
FROM sys.dm_os_performance_counters
WHERE counter_name = 'Log File(s) Size (KB)';

-- More useful: used vs total
SELECT
    DB_NAME()                                       AS db_name,
    total_log_size_mb   = SUM(total_log_size_in_bytes) / 1048576.0,
    used_log_space_mb   = SUM(used_log_space_in_bytes) / 1048576.0,
    used_pct            = 100.0 * SUM(used_log_space_in_bytes)
                              / NULLIF(SUM(total_log_size_in_bytes), 0)
FROM sys.dm_db_log_space_usage;

-- Alert if log > 80% full
SELECT DB_NAME(vfs.database_id) AS db_name,
       mf.name,
       vfs.io_stall_write_ms / NULLIF(vfs.num_of_writes,0) AS avg_write_ms
FROM sys.dm_io_virtual_file_stats(NULL,NULL) vfs
JOIN sys.master_files mf ON vfs.database_id=mf.database_id AND vfs.file_id=mf.file_id
WHERE mf.type = 1  -- log files
ORDER BY avg_write_ms DESC;

Read Next

← SQL Server Hub