The SQLITE_READONLY_RECOVERY error occurs when SQLite is attempting to recover a WAL (Write-Ahead Logging) mode database but lacks the necessary write permissions to complete the recovery process. This typically happens when a database connection was improperly closed or crashed, and SQLite needs to replay uncommitted changes from the WAL file back to the main database but is prevented by insufficient file system permissions.
The "SQLITE_READONLY_RECOVERY: WAL mode database cannot be modified" error is an extended result code of SQLITE_READONLY, specifically indicating that SQLite cannot complete WAL recovery operations due to read-only access restrictions. WAL (Write-Ahead Logging) mode is an alternative journaling mechanism in SQLite that provides better concurrency and performance. When using WAL mode, SQLite creates two additional files alongside the main database: 1. **database.db-wal**: The write-ahead log file containing recent changes 2. **database.db-shm**: The shared memory index file for coordinating access When a database connection is opened, SQLite may need to perform WAL recovery if: - A previous connection terminated unexpectedly (crash, power loss, forced process termination) - Uncommitted transactions exist in the WAL file - The WAL file needs to be checkpointed (merged) back into the main database The SQLITE_READONLY_RECOVERY error occurs when SQLite attempts this recovery process but encounters read-only restrictions. Common scenarios include: **Insufficient File Permissions**: The process opening the database has read permissions on the database file but lacks write permissions on one or more of: - The main database file (.db) - The WAL file (.db-wal) - The shared memory file (.db-shm) - The containing directory (required to create/modify auxiliary files) **Read-Only Database Access Attempts**: Opening a WAL mode database with read-only flags when recovery is needed. SQLite cannot open a WAL database in truly read-only mode if the .db-shm file exists or needs to be created, because managing the shared memory index requires write access. **File System Restrictions**: The database files are located on read-only media (CD-ROM, read-only mounted filesystem, application bundle) while still in WAL mode. **Platform-Specific Issues**: Some platforms or deployment environments (iOS app bundles, Android assets, containerized environments) impose read-only restrictions on database files. The error is SQLite's way of saying: "I need to perform maintenance on this database to ensure data consistency, but I don't have permission to do so."
First, check the permissions on all database-related files. WAL mode requires write access to the database file, WAL file, shared memory file, and containing directory.
Identify all related files:
# List database and WAL-related files
ls -la /path/to/database.db*
# Example output:
# -rw-r--r-- 1 user group 8192 Dec 22 database.db
# -rw-r--r-- 1 user group 4096 Dec 22 database.db-wal
# -rw-r--r-- 1 user group 512 Dec 22 database.db-shmCheck directory permissions:
# Check containing directory permissions
ls -ld /path/to/
# Output should show write permissions: drwxr-xr-x or similarFix permissions for the database files and directory:
# Grant read/write permissions to database files
chmod 664 /path/to/database.db
chmod 664 /path/to/database.db-wal
chmod 664 /path/to/database.db-shm
# Grant read/write/execute permissions on directory
chmod 775 /path/to/
# If needed, change ownership to the process user
sudo chown user:group /path/to/database.db*
sudo chown user:group /path/to/For application servers, ensure the process user has proper permissions:
# Example: For a web application running as www-data
sudo chown -R www-data:www-data /path/to/database/
sudo chmod -R 775 /path/to/database/
# Verify permissions
su - www-data -s /bin/bash -c "ls -la /path/to/database.db"Verify permissions programmatically:
import os
import stat
db_path = '/path/to/database.db'
# Check if file is writable
if os.access(db_path, os.W_OK):
print(f"{db_path} is writable")
else:
print(f"{db_path} is NOT writable")
# Check directory permissions
db_dir = os.path.dirname(db_path)
if os.access(db_dir, os.W_OK | os.X_OK):
print(f"Directory {db_dir} is writable and executable")
else:
print(f"Directory {db_dir} lacks necessary permissions")If the database is stuck in a recovery state, open it once with full write permissions to allow SQLite to complete the recovery process.
Using SQLite command-line tool:
# Open database to trigger recovery
sqlite3 /path/to/database.db "PRAGMA integrity_check;"
# Check WAL mode status
sqlite3 /path/to/database.db "PRAGMA journal_mode;"
# Force WAL checkpoint to merge changes
sqlite3 /path/to/database.db "PRAGMA wal_checkpoint(FULL);"Using Python:
import sqlite3
# Open database with write permissions to allow recovery
conn = sqlite3.connect('/path/to/database.db')
cursor = conn.cursor()
# Check database integrity
cursor.execute("PRAGMA integrity_check")
print("Integrity check:", cursor.fetchone()[0])
# Verify journal mode
cursor.execute("PRAGMA journal_mode")
print("Journal mode:", cursor.fetchone()[0])
# Checkpoint WAL file (merge into main database)
cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)")
result = cursor.fetchone()
print(f"Checkpoint result: {result}")
# Returns (busy, log_size, checkpointed_frames)
conn.close()
print("Recovery complete")Using Node.js:
const Database = require('better-sqlite3');
// Open database with write permissions
const db = new Database('/path/to/database.db');
// Check integrity
const integrity = db.pragma('integrity_check');
console.log('Integrity check:', integrity);
// Checkpoint WAL
const checkpoint = db.pragma('wal_checkpoint(TRUNCATE)');
console.log('Checkpoint result:', checkpoint);
db.close();
console.log('Recovery complete');After successful recovery, the database should open normally even with read-only permissions (assuming no further recovery is needed).
If you need to deploy the database to a read-only location or want to avoid WAL-related permission issues, convert it to DELETE journal mode before deployment.
Important: Perform this conversion on a writable copy of the database, then deploy the converted version.
Using SQLite command-line:
# Convert from WAL to DELETE mode
sqlite3 /path/to/database.db "PRAGMA journal_mode=DELETE;"
# Verify the change
sqlite3 /path/to/database.db "PRAGMA journal_mode;"
# Should output: delete
# Vacuum to clean up (optional but recommended)
sqlite3 /path/to/database.db "VACUUM;"
# Remove WAL and SHM files (they're no longer needed)
rm -f /path/to/database.db-wal
rm -f /path/to/database.db-shmUsing Python:
import sqlite3
import os
db_path = '/path/to/database.db'
# Connect to database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Convert to DELETE mode
cursor.execute("PRAGMA journal_mode=DELETE")
new_mode = cursor.fetchone()[0]
print(f"Journal mode changed to: {new_mode}")
# Checkpoint and cleanup
cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)")
print("WAL checkpoint complete")
# Optional: vacuum to optimize
cursor.execute("VACUUM")
print("Database vacuumed")
conn.close()
# Remove WAL and SHM files
wal_file = db_path + '-wal'
shm_file = db_path + '-shm'
if os.path.exists(wal_file):
os.remove(wal_file)
print(f"Removed {wal_file}")
if os.path.exists(shm_file):
os.remove(shm_file)
print(f"Removed {shm_file}")
print("Database ready for read-only deployment")Using Node.js:
const Database = require('better-sqlite3');
const fs = require('fs');
const path = require('path');
const dbPath = '/path/to/database.db';
const db = new Database(dbPath);
// Convert to DELETE mode
const result = db.pragma('journal_mode = DELETE');
console.log('Journal mode:', result);
// Checkpoint and vacuum
db.pragma('wal_checkpoint(TRUNCATE)');
db.pragma('vacuum');
db.close();
// Remove auxiliary files
const walFile = dbPath + '-wal';
const shmFile = dbPath + '-shm';
[walFile, shmFile].forEach(file => {
if (fs.existsSync(file)) {
fs.unlinkSync(file);
console.log(`Removed ${path.basename(file)}`);
}
});
console.log('Database converted to DELETE mode');After conversion, the database can be safely deployed to read-only media or opened with read-only flags without triggering SQLITE_READONLY_RECOVERY errors.
If your application needs to open databases in read-only mode, implement proper error handling and fallback strategies.
Python example with error handling:
import sqlite3
import os
def open_database_readonly(db_path):
"""
Open database in read-only mode with proper error handling.
Returns connection if successful, None otherwise.
"""
try:
# First, try read-only mode
conn = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
return conn
except sqlite3.OperationalError as e:
if 'SQLITE_READONLY_RECOVERY' in str(e):
print(f"WAL recovery needed for {db_path}")
# Check if we can perform recovery
if os.access(db_path, os.W_OK):
print("Attempting WAL recovery...")
# Open with write permissions to complete recovery
temp_conn = sqlite3.connect(db_path)
temp_conn.execute("PRAGMA wal_checkpoint(FULL)")
temp_conn.close()
# Now try read-only mode again
return sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
else:
print(f"Cannot recover: insufficient permissions on {db_path}")
return None
else:
raise # Re-raise other errors
# Usage
conn = open_database_readonly('/path/to/database.db')
if conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM table LIMIT 10")
print(cursor.fetchall())
conn.close()
else:
print("Failed to open database")Node.js example with better-sqlite3:
const Database = require('better-sqlite3');
const fs = require('fs');
function openDatabaseReadonly(dbPath) {
try {
// Attempt read-only connection
const db = new Database(dbPath, { readonly: true });
return db;
} catch (error) {
if (error.message.includes('SQLITE_READONLY_RECOVERY')) {
console.log(`WAL recovery needed for ${dbPath}`);
// Check if file is writable
try {
fs.accessSync(dbPath, fs.constants.W_OK);
// Perform recovery with write access
console.log('Attempting WAL recovery...');
const tempDb = new Database(dbPath);
tempDb.pragma('wal_checkpoint(FULL)');
tempDb.close();
// Retry read-only connection
return new Database(dbPath, { readonly: true });
} catch (permError) {
console.error('Cannot recover: insufficient permissions');
return null;
}
} else {
throw error; // Re-throw other errors
}
}
}
// Usage
const db = openDatabaseReadonly('/path/to/database.db');
if (db) {
const rows = db.prepare('SELECT * FROM table LIMIT 10').all();
console.log(rows);
db.close();
} else {
console.log('Failed to open database');
}For mobile apps (iOS/Android), copy database from bundle to writable location:
// React Native example
import RNFS from 'react-native-fs';
import SQLite from 'react-native-sqlite-storage';
async function setupDatabase() {
const bundleDbPath = RNFS.MainBundlePath + '/database.db';
const writableDbPath = RNFS.DocumentDirectoryPath + '/database.db';
// Check if database needs to be copied
const exists = await RNFS.exists(writableDbPath);
if (!exists) {
console.log('Copying database to writable location...');
await RNFS.copyFile(bundleDbPath, writableDbPath);
}
// Open from writable location
const db = await SQLite.openDatabase({
name: 'database.db',
location: 'default' // Uses DocumentDirectory
});
return db;
}Ensure databases are deployed to writable locations and have proper permissions in production environments.
Docker Deployment:
# Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
# Copy application code
COPY . .
# Create writable database directory
RUN mkdir -p /app/data && chmod 777 /app/data
# If bundling a database, convert to DELETE mode first
# COPY database.db /app/data/
# RUN chmod 666 /app/data/database.db
USER node
CMD ["node", "server.js"]Docker Compose with volume:
version: '3.8'
services:
app:
build: .
volumes:
# Mount database directory as writable volume
- ./database:/app/data
environment:
- DATABASE_PATH=/app/data/database.db
user: "node"Kubernetes Deployment:
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: database-storage
mountPath: /app/data
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000 # Ensures volume files are writable
volumes:
- name: database-storage
persistentVolumeClaim:
claimName: database-pvcCI/CD Pipeline Preparation:
#!/bin/bash
# prepare-database.sh
DB_FILE="database.db"
# Convert to DELETE mode before deployment
sqlite3 "$DB_FILE" "PRAGMA journal_mode=DELETE;"
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE);"
sqlite3 "$DB_FILE" "VACUUM;"
# Remove WAL files
rm -f "$DB_FILE-wal" "$DB_FILE-shm"
# Set appropriate permissions
chmod 644 "$DB_FILE"
echo "Database prepared for deployment"iOS App Bundle:
// Swift example: Copy database from bundle to Documents
func setupDatabase() throws -> URL {
let fileManager = FileManager.default
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
let destURL = documentsURL.appendingPathComponent("database.db")
// Only copy if not already in Documents
if !fileManager.fileExists(atPath: destURL.path) {
guard let bundleURL = Bundle.main.url(forResource: "database", withExtension: "db") else {
throw DatabaseError.bundleDatabaseNotFound
}
try fileManager.copyItem(at: bundleURL, to: destURL)
print("Database copied to: \(destURL.path)")
}
return destURL
}
// Open database from writable location
let dbURL = try setupDatabase()
let db = try Connection(dbURL.path)Environment Variable Configuration:
# .env file
DATABASE_PATH=/var/lib/app/database.db
DATABASE_WRITABLE=true
# Startup script
#!/bin/bash
# Ensure database directory exists and is writable
mkdir -p "$(dirname "$DATABASE_PATH")"
chmod 755 "$(dirname "$DATABASE_PATH")"
# Start application
node server.jsOn Linux systems with mandatory access control, SELinux or AppArmor may prevent database modifications even with correct file permissions.
Check SELinux status and context:
# Check if SELinux is enabled
getenforce
# Output: Enforcing, Permissive, or Disabled
# Check SELinux context of database files
ls -Z /path/to/database.db*
# Example output showing wrong context:
# -rw-rw-r--. user group unconfined_u:object_r:user_home_t:s0 database.dbFix SELinux context:
# Set correct context for web application
sudo chcon -t httpd_sys_rw_content_t /path/to/database.db*
sudo chcon -t httpd_sys_rw_content_t /path/to/database/
# Or use semanage for persistent rules
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/path/to/database(/.*)?"
sudo restorecon -R /path/to/database/
# Verify new context
ls -Z /path/to/database.dbTroubleshoot SELinux denials:
# Check audit logs for denials
sudo ausearch -m avc -ts recent | grep database
# If denials found, use audit2allow to create policy
sudo ausearch -m avc -ts recent | audit2allow -M mydatabase
sudo semodule -i mydatabase.pp
# Or temporarily set to permissive mode for testing (NOT for production)
sudo setenforce 0Check AppArmor status:
# Check if AppArmor is active
sudo aa-status
# Check for your application profile
sudo aa-status | grep your-appModify AppArmor profile:
# Edit profile (example for Node.js app)
sudo nano /etc/apparmor.d/usr.bin.node
# Add database access rules:
# /path/to/database/** rw,
# /path/to/database/*.db-wal rw,
# /path/to/database/*.db-shm rw,
# Reload profile
sudo apparmor_parser -r /etc/apparmor.d/usr.bin.node
# Or temporarily disable for testing
sudo aa-complain /usr/bin/nodeSystemd service with proper permissions:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="DATABASE_PATH=/var/lib/myapp/database.db"
# Ensure writable directory access
ReadWritePaths=/var/lib/myapp
# Or use StateDirectory to auto-create with correct permissions
StateDirectory=myapp
ExecStart=/usr/bin/node server.js
[Install]
WantedBy=multi-user.targetDocker with SELinux:
# Run container with proper SELinux context
docker run -v /path/to/database:/data:z myapp
# The :z flag relabels volume content for container access
# Use :Z for exclusive access by one containerSQLite WAL Mode Architecture and Recovery:
WAL (Write-Ahead Logging) mode fundamentally changes how SQLite handles transactions. Instead of modifying the main database file immediately, changes are appended to a separate WAL file. This provides several benefits:
1. Better Concurrency: Readers don't block writers, and writers don't block readers
2. Improved Performance: Sequential writes are faster than random writes
3. Atomic Commits: Transactions are atomic even if they span multiple database pages
The three files in WAL mode:
- database.db: Main database file (may not reflect latest committed transactions)
- database.db-wal: Write-ahead log containing recent commits
- database.db-shm: Shared memory index for coordinating access (header and lock bytes)
WAL Recovery Process:
When opening a WAL mode database, SQLite performs these checks:
1. Examines the WAL file header to validate it matches the database
2. Checks for uncommitted transactions or unmerged commits
3. If recovery is needed, replays valid WAL frames back to the main database
4. Updates checksum and metadata
Recovery requires write access because:
- The main database may need updating with WAL content
- The shared memory file must be created/updated
- Lock bytes need to be set for coordination
- Checksum validation requires temporary writes
Read-Only Database Limitations in WAL Mode:
SQLite's documentation explicitly states: "It is not possible to open read-only WAL databases." More precisely:
- If the .db-shm file exists, opening the database requires write access to it
- If the .db-shm file doesn't exist, write access to the directory is required to create it
- Even pure readers need write access to the shared memory file for coordination
The only way to truly open a WAL database read-only is to:
1. Convert it to DELETE journal mode first
2. Remove the -wal and -shm files
3. Deploy the single .db file to read-only media
Checkpointing Strategies:
Checkpointing merges WAL content into the main database. SQLite supports several checkpoint modes:
import sqlite3
conn = sqlite3.connect('database.db')
# PASSIVE: Checkpoint only if no readers (default for auto-checkpoints)
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
# FULL: Wait for readers, then checkpoint entire WAL
conn.execute("PRAGMA wal_checkpoint(FULL)")
# RESTART: Like FULL, but also resets WAL file
conn.execute("PRAGMA wal_checkpoint(RESTART)")
# TRUNCATE: Like RESTART, but also truncates WAL to zero bytes
result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
print(f"Busy: {result[0]}, Log size: {result[1]}, Checkpointed: {result[2]}")WAL Mode Configuration Options:
-- Enable WAL mode
PRAGMA journal_mode = WAL;
-- Set WAL auto-checkpoint threshold (pages)
PRAGMA wal_autocheckpoint = 1000; -- Default is 1000 pages
-- Check WAL file size
SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size();
-- Disable auto-checkpoint (manual control)
PRAGMA wal_autocheckpoint = 0;Platform-Specific Considerations:
1. iOS/macOS: SQLite in app bundles is read-only. Always copy to Documents or Library directory.
2. Android: Databases in assets are read-only. Copy to internal storage or external storage.
3. Windows: UWP apps have restricted filesystem access. Use ApplicationData folders.
4. Linux: SELinux can prevent modifications even with 0777 permissions.
5. Docker: Bind mounts may have permission mismatches between host and container UIDs.
Performance Implications:
WAL mode vs DELETE mode trade-offs:
| Aspect | WAL Mode | DELETE Mode |
|--------|----------|-------------|
| Read concurrency | Excellent (readers don't block) | Poor (readers block writers) |
| Write performance | Better (sequential writes) | Slower (random I/O) |
| File complexity | 3 files (.db, .db-wal, .db-shm) | 1-2 files (.db, .db-journal) |
| Read-only support | Difficult/impossible | Easy |
| Crash recovery | Automatic via WAL replay | Automatic via journal rollback |
| Storage overhead | WAL can grow large | Journal only during active transactions |
Debugging WAL Issues:
Check WAL file status:
# View WAL file contents
sqlite3 database.db ".dump" > /dev/null 2>&1 # Forces checkpoint
ls -lh database.db*
# Check for WAL corruption
sqlite3 database.db "PRAGMA integrity_check"
sqlite3 database.db "PRAGMA wal_checkpoint(TRUNCATE)"Enable SQLite debugging:
import sqlite3
import logging
# Enable SQLite debug logging (requires compilation flag)
sqlite3.enable_callback_tracebacks(True)
# Check SQLite compile options
conn = sqlite3.connect(':memory:')
for row in conn.execute("PRAGMA compile_options"):
print(row[0])Alternative Journal Modes:
If WAL causes persistent issues, consider alternatives:
-- DELETE mode: Default, deletes journal after each transaction
PRAGMA journal_mode = DELETE;
-- TRUNCATE mode: Truncates journal instead of deleting
PRAGMA journal_mode = TRUNCATE;
-- PERSIST mode: Keeps journal file, zeroes header
PRAGMA journal_mode = PERSIST;
-- MEMORY mode: Journal in RAM (faster but less safe)
PRAGMA journal_mode = MEMORY;
-- OFF mode: No journaling (dangerous, only for temporary DBs)
PRAGMA journal_mode = OFF;When to Use WAL Mode:
- Applications with high read concurrency
- Write-heavy workloads with sequential writes
- Local databases with guaranteed write permissions
- Databases that won't be deployed to read-only media
When to Avoid WAL Mode:
- Databases deployed to read-only media
- Mobile app bundles (iOS/Android)
- Network filesystems (NFS, SMB) - WAL requires proper file locking
- Scenarios requiring database portability as single file
- Systems with restrictive permission models
SQLITE_BUSY: The database file is locked
How to fix 'SQLITE_BUSY: The database file is locked' in SQLite
better-sqlite3: This statement has already been finalized
How to fix "better-sqlite3: This statement has already been finalized" in SQLite
SQLITE_AUTH: Authorization denied
SQLITE_AUTH: Authorization denied
SQLITE_CONSTRAINT_CHECK: CHECK constraint failed
CHECK constraint failed in SQLite
SQLITE_ERROR: SQL logic error
How to fix "SQLITE_ERROR: SQL logic error" in SQLite