How to fix SQLITE_BUSY_SNAPSHOT: Cannot promote read transaction to write transaction in SQLite

SQLiteINTERMEDIATEMEDIUMUpdated Dec 12, 2025· 256 views

This error occurs when SQLite cannot upgrade a read transaction to a write transaction due to snapshot isolation conflicts. It happens when a transaction starts in read mode, but later tries to write while other transactions are accessing the same data snapshot.

Quick fix

Fixes SQLITE_BUSY_SNAPSHOT

-- DEFERRED (default): Transaction starts as read, can upgrade to write
BEGIN DEFERRED;

-- IMMEDIATE: Transaction starts as write, prevents other writes
BEGIN IMMEDIATE;

-- EXCLUSIVE: Exclusive lock on database
BEGIN EXCLUSIVE;

-- SNAPSHOT: Read-only snapshot, cannot upgrade to write
BEGIN SNAPSHOT;
  1. Understand SQLite's transaction isolation levels
  2. Change transaction isolation level from SNAPSHOT to DEFERRED
  3. Restructure application logic to separate reads and writes
  4. Configure connection and transaction settings
sqlite — error
-- DEFERRED (default): Transaction starts as read, can upgrade to write
BEGIN DEFERRED;
-- IMMEDIATE: Transaction starts as write, prevents other writes
BEGIN IMMEDIATE;
-- EXCLUSIVE: Exclusive lock on database
BEGIN EXCLUSIVE;
-- SNAPSHOT: Read-only snapshot, cannot upgrade to write
BEGIN SNAPSHOT;
SQLITE_BUSY_SNAPSHOT: Cannot promote read transaction to write transaction

What this error means

SQLITE_BUSY_SNAPSHOT is a concurrency error specific to SQLite's snapshot isolation feature. It occurs when a transaction that began as a read-only transaction attempts to upgrade to a write transaction, but cannot do so because other transactions are still viewing the same database snapshot. SQLite's snapshot isolation allows multiple transactions to see a consistent view of the database at a specific point in time. When a transaction starts with SNAPSHOT isolation level, it captures a snapshot of the database state. If this transaction later tries to write (upgrade from read to write), SQLite must ensure no other transactions are still viewing that same snapshot. The error typically appears when: 1. **Transaction A** starts with SNAPSHOT isolation and reads data 2. **Transaction B** modifies data that Transaction A might read 3. **Transaction A** tries to write data, but Transaction B (or others) are still active 4. **Multiple long-running transactions** exist with overlapping snapshots This is a fundamental limitation of snapshot isolation: once a transaction captures a snapshot, it cannot write if other transactions might conflict with that snapshot view.

Typical symptoms

  • Database operations fail with 'SQLITE_BUSY_SNAPSHOT: Cannot promote read transaction to write transaction'
  • Error occurs when a read transaction attempts to perform write operations
  • Applications using SNAPSHOT isolation level for transactions
  • Errors appear in multi-threaded or multi-process applications
  • Long-running transactions that start as reads but later need to write
  • Intermittent failures under high concurrency
  • Works in development but fails in production with multiple users

Common causes

  1. Starting a transaction with BEGIN SNAPSHOT and later trying to write
  2. Multiple concurrent transactions with overlapping lifetimes
  3. Long-running read transactions that block write promotion
  4. Using SNAPSHOT isolation without understanding its write limitations
  5. Application design that starts transactions early but writes later
  6. Connection pooling where transactions span multiple operations
  7. ORM frameworks that automatically manage transaction boundaries
  8. Not closing read transactions before attempting writes

How to fix "SQLITE_BUSY_SNAPSHOT: Cannot promote read transaction to write transaction"

1Understand SQLite's transaction isolation levels

SQLite supports several transaction isolation levels. The SNAPSHOT level has specific limitations:

Available isolation levels:

sql
-- DEFERRED (default): Transaction starts as read, can upgrade to write
BEGIN DEFERRED;

-- IMMEDIATE: Transaction starts as write, prevents other writes
BEGIN IMMEDIATE;

-- EXCLUSIVE: Exclusive lock on database
BEGIN EXCLUSIVE;

-- SNAPSHOT: Read-only snapshot, cannot upgrade to write
BEGIN SNAPSHOT;

Key differences:
- DEFERRED: Can start as read, upgrade to write when needed
- IMMEDIATE: Starts as write, prevents other writes but allows reads
- EXCLUSIVE: Exclusive access, no other connections allowed
- SNAPSHOT: Read-only snapshot, cannot write at all

When to use each:
- Use DEFERRED for most applications (default)
- Use IMMEDIATE when you know you'll write
- Use EXCLUSIVE for batch operations or migrations
- Use SNAPSHOT only for consistent read-only views

2Change transaction isolation level from SNAPSHOT to DEFERRED

If your application needs to write after reading, use DEFERRED instead of SNAPSHOT:

Before (causes SQLITE_BUSY_SNAPSHOT):

sql
BEGIN SNAPSHOT;
SELECT * FROM users WHERE id = 1;
-- Later in code...
UPDATE users SET name = 'John' WHERE id = 1; -- ERROR!
COMMIT;

After (works correctly):

sql
BEGIN DEFERRED;
SELECT * FROM users WHERE id = 1;
-- Can now write...
UPDATE users SET name = 'John' WHERE id = 1; -- SUCCESS
COMMIT;

In application code:

Python example:

python
import sqlite3

# Wrong - using SNAPSHOT when writes might occur
conn = sqlite3.connect('database.db')
conn.execute('BEGIN SNAPSHOT')  # Will fail if writing later

# Correct - use DEFERRED (default) when writes might occur
conn = sqlite3.connect('database.db')
conn.execute('BEGIN')  # Defaults to DEFERRED, can upgrade to write

Node.js with better-sqlite3:

javascript
const Database = require('better-sqlite3');
const db = new Database('database.db');

// Wrong
const transaction = db.transaction(() => {
  // This uses SNAPSHOT internally if unspecified
});

// Correct - specify DEFERRED
const transaction = db.transaction(() => {
  // Your code
});
transaction.immediate = false; // Use DEFERRED mode
3Restructure application logic to separate reads and writes

If you need snapshot isolation for reads, separate read and write transactions:

Pattern 1: Read-then-write in separate transactions

python
def update_user(user_id, new_name):
    # 1. Read with SNAPSHOT
    with db_connection() as conn:
        conn.execute('BEGIN SNAPSHOT')
        cursor = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,))
        user = cursor.fetchone()
        conn.execute('COMMIT')

    # 2. Write with DEFERRED
    with db_connection() as conn:
        conn.execute('BEGIN DEFERRED')
        conn.execute('UPDATE users SET name = ? WHERE id = ?', (new_name, user_id))
        conn.execute('COMMIT')

Pattern 2: Use IMMEDIATE for write-only transactions

sql
-- If you know you'll write, start with IMMEDIATE
BEGIN IMMEDIATE;
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = 1;
COMMIT;

Pattern 3: Short-lived transactions

javascript
// Bad: Long transaction that might need to write
db.transaction(() => {
  const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
  // ... lots of other code ...
  if (user.needsUpdate) {
    db.prepare('UPDATE users SET updated = 1 WHERE id = ?').run(userId); // ERROR!
  }
});

// Good: Separate transactions
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
// ... logic ...
if (user.needsUpdate) {
  db.transaction(() => {
    db.prepare('UPDATE users SET updated = 1 WHERE id = ?').run(userId);
  });
}
4Configure connection and transaction settings

Adjust SQLite settings to better handle transaction promotion:

Set appropriate busy timeout:

sql
PRAGMA busy_timeout = 30000; -- Wait up to 30 seconds for locks

Enable WAL mode for better concurrency:

sql
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;

Configure connection for immediate transactions:

python
import sqlite3

def get_write_connection(db_path):
    conn = sqlite3.connect(db_path)
    # Start transaction immediately to avoid promotion issues
    conn.execute('BEGIN IMMEDIATE')
    return conn

def release_write_connection(conn):
    conn.commit()
    conn.close()

Use connection pools with transaction awareness:

javascript
class ConnectionPool {
  constructor(dbPath) {
    this.readConnections = []; // For SNAPSHOT/read-only
    this.writeConnections = []; // For DEFERRED/IMMEDIATE
  }

  getReadConnection() {
    // Returns connection configured for read-only
  }

  getWriteConnection() {
    // Returns connection ready for writing
  }
}
5Implement retry logic with exponential backoff

For applications that might occasionally hit SQLITE_BUSY_SNAPSHOT, implement retry logic:

Python retry decorator:

python
import time
import sqlite3
from functools import wraps

def retry_on_busy_snapshot(max_retries=3, base_delay=0.1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except sqlite3.OperationalError as e:
                    if 'SQLITE_BUSY_SNAPSHOT' in str(e) and attempt < max_retries - 1:
                        delay = base_delay * (2 ** attempt)  # Exponential backoff
                        time.sleep(delay)
                        continue
                    raise
            return func(*args, **kwargs)
        return wrapper
    return decorator

@retry_on_busy_snapshot(max_retries=3)
def update_with_retry(user_id, data):
    with sqlite3.connect('database.db') as conn:
        conn.execute('BEGIN')
        conn.execute('UPDATE users SET data = ? WHERE id = ?', (data, user_id))
        conn.commit()

JavaScript/Node.js retry pattern:

javascript
async function executeWithRetry(operation, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await operation();
    } catch (error) {
      if (error.code === 'SQLITE_BUSY_SNAPSHOT' && i < maxRetries - 1) {
        const delay = 100 * Math.pow(2, i); // 100ms, 200ms, 400ms
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
      throw error;
    }
  }
}

// Usage
await executeWithRetry(async () => {
  return db.transaction(() => {
    db.prepare('UPDATE users SET visits = visits + 1 WHERE id = ?').run(userId);
  });
});

Java retry pattern:

java
public <T> T retryOnBusySnapshot(Callable<T> operation, int maxRetries) throws Exception {
    for (int i = 0; i < maxRetries; i++) {
        try {
            return operation.call();
        } catch (SQLException e) {
            if (e.getMessage().contains("SQLITE_BUSY_SNAPSHOT") && i < maxRetries - 1) {
                Thread.sleep(100 * (int)Math.pow(2, i)); // Exponential backoff
                continue;
            }
            throw e;
        }
    }
    return operation.call();
}
6Monitor and debug transaction conflicts

Identify which transactions are causing snapshot conflicts:

Check active transactions:

sql
-- SQLite doesn't have a built-in way to see all transactions,
-- but you can monitor through application logging

-- Check database locks
SELECT * FROM pragma_lock_status;

-- Check WAL mode status
PRAGMA wal_checkpoint(TRUNCATE);

Add transaction logging to your application:

python
import logging
import sqlite3
import threading

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

class LoggingConnection:
    def __init__(self, db_path):
        self.conn = sqlite3.connect(db_path)
        self.thread_id = threading.get_ident()

    def execute(self, sql, params=()):
        logger.debug(f"[Thread {self.thread_id}] Executing: {sql}")
        try:
            return self.conn.execute(sql, params)
        except sqlite3.OperationalError as e:
            logger.error(f"[Thread {self.thread_id}] Error: {e}")
            raise

    def commit(self):
        logger.debug(f"[Thread {self.thread_id}] Committing transaction")
        self.conn.commit()

    def close(self):
        logger.debug(f"[Thread {self.thread_id}] Closing connection")
        self.conn.close()

Use SQLite's trace functionality:

python
import sqlite3

def trace_callback(statement):
    print(f"SQL: {statement}")

conn = sqlite3.connect('database.db')
conn.set_trace_callback(trace_callback)

Monitor with external tools:

bash
# On Linux/macOS, monitor file locks
lsof database.db

# Check for lock files
ls -la database.db-*

# Monitor with strace (advanced)
strace -f -e trace=file python your_script.py

Advanced notes

### SQLite Snapshot Isolation Deep Dive

SQLite's SNAPSHOT isolation level provides a consistent view of the database at the moment the transaction begins. This is implemented using SQLite's write-ahead logging (WAL) mode.

How snapshot isolation works:
1. When a transaction starts with BEGIN SNAPSHOT, SQLite records the current WAL frame number
2. All reads in that transaction see the database as it existed at that frame
3. Other transactions can write new data, creating new WAL frames
4. The snapshot transaction continues to see the old data

Why write promotion fails:
- To write, SQLite would need to "upgrade" the snapshot to a write transaction
- This requires checking if any other transactions are still viewing the same snapshot
- If other transactions exist, promotion fails with SQLITE_BUSY_SNAPSHOT
- This preserves snapshot consistency guarantees

### Transaction Isolation Levels Comparison

| Level | Read Consistency | Write Ability | Concurrency | Use Case |
|-------|------------------|---------------|-------------|----------|
| DEFERRED | Statement-level | Can upgrade | High | General purpose, mixed read/write |
| IMMEDIATE | Statement-level | Immediate write | Medium | Known writes, prevent deadlocks |
| EXCLUSIVE | Statement-level | Exclusive write | Low | Batch operations, migrations |
| SNAPSHOT | Transaction-level | None | High | Consistent read-only views |

### WAL Mode and Snapshot Isolation

Snapshot isolation requires WAL mode. Key considerations:

WAL file management:
- Snapshot transactions keep old WAL frames alive
- This can cause WAL file growth
- Regular checkpointing is essential
- Use PRAGMA wal_autocheckpoint to automate

Checkpoint types:
- PASSIVE: Checkpoint if no writers (default)
- FULL: Wait for readers, then checkpoint
- RESTART: Like FULL, but reset WAL
- TRUNCATE: Like RESTART, also truncate WAL

### Application Architecture Patterns

Pattern 1: CQRS (Command Query Responsibility Segregation)
- Use SNAPSHOT for queries (reads)
- Use DEFERRED/IMMEDIATE for commands (writes)
- Separate read and write connections

Pattern 2: Transaction Script
- Keep transactions short and focused
- Start with appropriate isolation level
- Never mix long-running reads with writes

Pattern 3: Optimistic Concurrency Control
- Read data with SNAPSHOT
- Compute changes locally
- Write with DEFERRED, check for conflicts
- Retry on conflict

### Performance Considerations

Snapshot overhead:
- Each snapshot transaction pins WAL frames
- More snapshots = larger WAL files
- Can impact checkpoint performance

Memory usage:
- Snapshot transactions cache pages
- Large working sets increase memory pressure
- Consider PRAGMA cache_size tuning

Connection pooling:
- Pool read and write connections separately
- Label connections by intended use
- Monitor pool usage patterns

### Migration Strategies

If you're encountering SQLITE_BUSY_SNAPSHOT in production:

1. Immediate fix: Change BEGIN SNAPSHOT to BEGIN DEFERRED
2. Medium-term: Separate read and write operations
3. Long-term: Consider database sharding or replication
4. Alternative: Use PostgreSQL for complex transaction needs

### Testing Snapshot Behavior

Create test scenarios:

python
def test_snapshot_conflict():
    # Thread 1: Start snapshot, read
    # Thread 2: Write data
    # Thread 1: Try to write (should fail)
    # Verify error is SQLITE_BUSY_SNAPSHOT

### Monitoring and Alerting

Monitor for:
- Increasing SQLITE_BUSY_SNAPSHOT errors
- WAL file size growth
- Long-running snapshot transactions
- Checkpoint frequency and duration

Set alerts for:
- SQLITE_BUSY_SNAPSHOT error rate spikes
- WAL file exceeding size threshold
- Checkpoint duration exceeding SLA

Frequently asked questions

What does "SQLITE_BUSY_SNAPSHOT" mean?
SQLITE_BUSY_SNAPSHOT is a concurrency error specific to SQLite's snapshot isolation feature. It occurs when a transaction that began as a read-only transaction attempts to upgrade to a write transaction, but cannot do so because other transactions are still viewing the same database snapshot. SQLite's snapshot isolation allows multiple transactions to see a consistent view of the database at a specific point in time.
How do I fix "SQLITE_BUSY_SNAPSHOT"?
This error occurs when SQLite cannot upgrade a read transaction to a write transaction due to snapshot isolation conflicts. It happens when a transaction starts in read mode, but later tries to write while other transactions are accessing the same data snapshot.
What causes "SQLITE_BUSY_SNAPSHOT"?
Starting a transaction with BEGIN SNAPSHOT and later trying to write Multiple concurrent transactions with overlapping lifetimes Long-running read transactions that block write promotion
Is the "SQLITE_BUSY_SNAPSHOT" error serious?
It is a medium-severity error — more of a blocker than a risk. The steps above resolve it.

Official resources & further reading