SQLite WAL Mode: Write-Ahead Logging

• Write-Ahead Logging (WAL) mode eliminates the read-write lock contention of SQLite's default rollback journal mode, allowing concurrent reads while writes are in progress

Key Insights

• Write-Ahead Logging (WAL) mode eliminates the read-write lock contention of SQLite’s default rollback journal mode, allowing concurrent reads while writes are in progress • WAL mode delivers 2-3x write performance improvement for most workloads by batching commits and reducing disk I/O through sequential append operations • Checkpoint operations control WAL file growth and determine when changes are flushed to the main database file, requiring careful tuning for production deployments

Understanding WAL Mode Architecture

SQLite’s default rollback journal mode copies pages before modification, creating a recovery log. WAL mode inverts this: it appends all changes to a separate write-ahead log file while keeping the main database file unchanged until checkpoint operations flush accumulated changes.

When you enable WAL mode, SQLite creates two additional files alongside your database:

  • database.db-wal - The write-ahead log containing recent changes
  • database.db-shm - Shared memory index for coordinating readers and writers

The WAL file grows as transactions commit. Readers access the main database file plus the WAL, constructing a consistent view by applying relevant WAL entries. This architecture enables true concurrent read-write access.

import sqlite3
import threading
import time

# Enable WAL mode
conn = sqlite3.connect('app.db')
conn.execute('PRAGMA journal_mode=WAL')
print(conn.execute('PRAGMA journal_mode').fetchone())  # Returns ('wal',)

# Create test table
conn.execute('''
    CREATE TABLE IF NOT EXISTS events (
        id INTEGER PRIMARY KEY,
        timestamp REAL,
        data TEXT
    )
''')
conn.commit()

# Writer thread
def writer():
    write_conn = sqlite3.connect('app.db')
    for i in range(100):
        write_conn.execute(
            'INSERT INTO events (timestamp, data) VALUES (?, ?)',
            (time.time(), f'event_{i}')
        )
        write_conn.commit()
        time.sleep(0.01)
    write_conn.close()

# Reader thread
def reader():
    read_conn = sqlite3.connect('app.db')
    for _ in range(100):
        cursor = read_conn.execute('SELECT COUNT(*) FROM events')
        count = cursor.fetchone()[0]
        print(f'Read count: {count}')
        time.sleep(0.01)
    read_conn.close()

# Concurrent execution - readers never block
writer_thread = threading.Thread(target=writer)
reader_thread = threading.Thread(target=reader)

writer_thread.start()
reader_thread.start()

writer_thread.join()
reader_thread.join()

conn.close()

Performance Characteristics

WAL mode transforms SQLite’s write performance profile. Instead of synchronous writes that modify the database file directly, WAL appends transaction records sequentially. This reduces random I/O and enables more efficient disk utilization.

package main

import (
    "database/sql"
    "fmt"
    "log"
    "time"
    _ "github.com/mattn/go-sqlite3"
)

func benchmarkMode(dbPath string, walMode bool) time.Duration {
    db, err := sql.Open("sqlite3", dbPath)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if walMode {
        db.Exec("PRAGMA journal_mode=WAL")
        db.Exec("PRAGMA synchronous=NORMAL")
    } else {
        db.Exec("PRAGMA journal_mode=DELETE")
        db.Exec("PRAGMA synchronous=FULL")
    }

    db.Exec("DROP TABLE IF EXISTS benchmark")
    db.Exec(`CREATE TABLE benchmark (
        id INTEGER PRIMARY KEY,
        value TEXT
    )`)

    start := time.Now()
    
    // Insert 10,000 rows in individual transactions
    for i := 0; i < 10000; i++ {
        db.Exec("INSERT INTO benchmark (value) VALUES (?)", 
            fmt.Sprintf("data_%d", i))
    }

    return time.Since(start)
}

func main() {
    rollbackTime := benchmarkMode("rollback.db", false)
    walTime := benchmarkMode("wal.db", true)

    fmt.Printf("Rollback mode: %v\n", rollbackTime)
    fmt.Printf("WAL mode: %v\n", walTime)
    fmt.Printf("Speedup: %.2fx\n", 
        float64(rollbackTime)/float64(walTime))
}

Typical results show 2-3x improvement for write-heavy workloads. WAL mode particularly excels with small transactions because it batches fsync operations.

Checkpoint Management

Checkpoints transfer WAL contents to the main database file. SQLite automatically checkpoints when the WAL reaches 1000 pages (typically 4MB), but production systems need explicit checkpoint control.

Three checkpoint modes exist:

  • PASSIVE: Checkpoint only if no readers are using old WAL data
  • FULL: Wait for readers, then checkpoint completely
  • RESTART: Full checkpoint plus reset WAL to beginning
  • TRUNCATE: Restart plus truncate WAL file to zero bytes
// Node.js example with better-sqlite3
const Database = require('better-sqlite3');
const db = new Database('app.db');

// Enable WAL
db.pragma('journal_mode = WAL');

// Configure checkpoint behavior
db.pragma('wal_autocheckpoint = 1000'); // Pages before auto-checkpoint

// Manual checkpoint function
function checkpoint(mode = 'PASSIVE') {
    const result = db.pragma(`wal_checkpoint(${mode})`);
    console.log(`Checkpoint ${mode}:`, result);
    // Returns: [{ busy: 0, log: pages_in_wal, checkpointed: pages_moved }]
}

// Insert workload
const insert = db.prepare('INSERT INTO events (timestamp, data) VALUES (?, ?)');

const insertMany = db.transaction((records) => {
    for (const record of records) {
        insert.run(record.timestamp, record.data);
    }
});

// Simulate workload
const records = Array.from({ length: 5000 }, (_, i) => ({
    timestamp: Date.now(),
    data: `event_${i}`
}));

insertMany(records);

// Force checkpoint after bulk insert
checkpoint('RESTART');

// Monitor WAL size
const walInfo = db.pragma('wal_checkpoint', { simple: true });
console.log('WAL status:', walInfo);

db.close();

Production Configuration

Production deployments require tuning several WAL parameters for optimal performance and reliability.

use rusqlite::{Connection, Result};

fn configure_wal_production(conn: &Connection) -> Result<()> {
    // Enable WAL mode
    conn.pragma_update(None, "journal_mode", "WAL")?;
    
    // Synchronous mode - NORMAL is safe with WAL
    // FULL is unnecessary overhead; WAL maintains consistency
    conn.pragma_update(None, "synchronous", "NORMAL")?;
    
    // Checkpoint threshold - adjust based on workload
    // Lower = smaller WAL files, more checkpoint overhead
    // Higher = larger WAL files, fewer checkpoints
    conn.pragma_update(None, "wal_autocheckpoint", 1000)?;
    
    // Busy timeout for lock contention
    conn.pragma_update(None, "busy_timeout", 5000)?;
    
    // Cache size - larger cache reduces I/O
    conn.pragma_update(None, "cache_size", -64000)?; // 64MB
    
    // Memory-mapped I/O for better read performance
    conn.pragma_update(None, "mmap_size", 268435456)?; // 256MB
    
    Ok(())
}

fn main() -> Result<()> {
    let conn = Connection::open("production.db")?;
    configure_wal_production(&conn)?;
    
    // Verify settings
    let journal_mode: String = conn.pragma_query_value(
        None, "journal_mode", |row| row.get(0)
    )?;
    println!("Journal mode: {}", journal_mode);
    
    // Create schema
    conn.execute(
        "CREATE TABLE IF NOT EXISTS metrics (
            id INTEGER PRIMARY KEY,
            timestamp INTEGER NOT NULL,
            metric_name TEXT NOT NULL,
            value REAL NOT NULL
        )",
        [],
    )?;
    
    // High-throughput insert pattern
    let tx = conn.transaction()?;
    {
        let mut stmt = tx.prepare_cached(
            "INSERT INTO metrics (timestamp, metric_name, value) VALUES (?, ?, ?)"
        )?;
        
        for i in 0..10000 {
            stmt.execute([
                i.to_string(),
                "cpu_usage".to_string(),
                (50.0 + (i as f64 % 50.0)).to_string(),
            ])?;
        }
    }
    tx.commit()?;
    
    // Explicit checkpoint after bulk operation
    conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
    
    Ok(())
}

WAL Mode Limitations

WAL mode has specific constraints that affect deployment decisions:

Network filesystems: WAL requires shared memory, which doesn’t work reliably over NFS or network-attached storage. Use rollback journal mode for networked databases.

Multiple processes on different hosts: The shared memory coordination mechanism only works for processes on the same machine accessing the same filesystem.

32-bit systems: WAL files cannot exceed 2GB on 32-bit systems due to memory-mapping limitations.

import sqlite3
import os

def check_wal_compatibility(db_path):
    """Verify WAL mode is appropriate for environment"""
    
    # Check if file is on network filesystem
    if os.path.ismount(os.path.dirname(db_path)):
        print("WARNING: Database on mount point - may be network filesystem")
        return False
    
    # Attempt WAL mode
    try:
        conn = sqlite3.connect(db_path)
        result = conn.execute('PRAGMA journal_mode=WAL').fetchone()
        
        if result[0].lower() != 'wal':
            print(f"WAL mode not supported, using: {result[0]}")
            return False
            
        # Test write to verify shared memory works
        conn.execute('CREATE TABLE IF NOT EXISTS wal_test (id INTEGER)')
        conn.execute('INSERT INTO wal_test VALUES (1)')
        conn.commit()
        conn.close()
        
        return True
        
    except sqlite3.OperationalError as e:
        print(f"WAL mode error: {e}")
        return False

# Usage
if check_wal_compatibility('app.db'):
    print("WAL mode compatible and configured")
else:
    print("Using rollback journal mode instead")

Monitoring and Maintenance

Track WAL performance metrics to identify checkpoint timing issues and file growth problems.

import sqlite3
from dataclasses import dataclass
from typing import Optional

@dataclass
class WALMetrics:
    wal_pages: int
    checkpointed_pages: int
    wal_file_size: int
    db_file_size: int
    checkpoint_busy: int

def get_wal_metrics(db_path: str) -> Optional[WALMetrics]:
    """Collect WAL performance metrics"""
    try:
        conn = sqlite3.connect(db_path)
        
        # Get checkpoint status
        checkpoint_info = conn.execute('PRAGMA wal_checkpoint').fetchone()
        busy, log_pages, checkpointed = checkpoint_info
        
        # Get file sizes
        import os
        db_size = os.path.getsize(db_path)
        wal_size = 0
        wal_path = f"{db_path}-wal"
        if os.path.exists(wal_path):
            wal_size = os.path.getsize(wal_path)
        
        conn.close()
        
        return WALMetrics(
            wal_pages=log_pages,
            checkpointed_pages=checkpointed,
            wal_file_size=wal_size,
            db_file_size=db_size,
            checkpoint_busy=busy
        )
    except Exception as e:
        print(f"Error collecting metrics: {e}")
        return None

# Monitor during operation
metrics = get_wal_metrics('app.db')
if metrics:
    print(f"WAL pages: {metrics.wal_pages}")
    print(f"WAL size: {metrics.wal_file_size / 1024 / 1024:.2f} MB")
    print(f"DB size: {metrics.db_file_size / 1024 / 1024:.2f} MB")
    
    # Alert if WAL grows too large
    if metrics.wal_file_size > 100 * 1024 * 1024:  # 100MB
        print("WARNING: WAL file exceeds 100MB - consider checkpoint")

WAL mode represents the optimal configuration for most SQLite deployments requiring concurrent access. The architectural shift from copy-on-write to append-only logging delivers substantial performance improvements while maintaining ACID guarantees. Proper checkpoint management and configuration tuning ensure production systems remain performant and reliable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.