The SQLITE_READONLY_CANTLOCK error occurs when SQLite cannot obtain a read lock on a WAL (Write-Ahead Logging) mode database because the shared-memory file is read-only. This typically happens when file permissions prevent SQLite from accessing or modifying the .db-shm file needed for WAL coordination.
The "SQLITE_READONLY_CANTLOCK: Unable to obtain a read lock" error is a specific extended result code of SQLITE_READONLY, indicating that SQLite cannot acquire the necessary read lock for a WAL mode database because the shared-memory file (.db-shm) is read-only or inaccessible. **Understanding WAL Mode and Locking:** SQLite's WAL (Write-Ahead Logging) mode uses a three-file architecture: 1. **database.db**: The main database file 2. **database.db-wal**: The write-ahead log containing recent transactions 3. **database.db-shm**: The shared memory file used for lock coordination Even for read-only access, WAL mode requires write access to the .db-shm file because SQLite uses specific bytes in this file to coordinate between readers and writers. The locks are implemented as atomic modifications to this shared memory file. **Why the Error Occurs:** The SQLITE_READONLY_CANTLOCK error specifically means: - SQLite is trying to open a WAL mode database - The process has read access to the main database file - The process CANNOT write to the .db-shm file (either it doesn't exist and can't be created, or it exists but is read-only) - WAL mode requires write access to .db-shm for lock coordination, making the database effectively unusable This is different from SQLITE_READONLY_RECOVERY which occurs when WAL recovery fails. CANTLOCK means the error happens before recovery even starts—SQLite can't get the fundamental locks needed to operate safely. **Common Scenarios:** 1. **Database deployed to read-only media** (CD-ROM, read-only filesystem, application bundle) 2. **File permissions on .db-shm are too restrictive** (0444 instead of 0664) 3. **Directory containing database is read-only** (prevents creating .db-shm if missing) 4. **Running in read-only container or sandbox** with restrictions on specific files 5. **SELinux or AppArmor** enforcing restrictions on the shared memory file specifically 6. **Database files copied from read-only source** with restrictive permissions preserved 7. **Network filesystem** with sync issues preventing proper shared memory access
First, check whether the .db-shm file exists and what permissions it has. The shared memory file must be writable by the process accessing the database.
Check for existence of WAL-related files:
# List all database-related files
ls -la /path/to/database.db*
# Example output (problematic permissions):
# -rw-r--r-- 1 user group 8192 Dec 22 database.db
# -r--r--r-- 1 user group 512 Dec 22 database.db-shm <- READ-ONLY!
# -rw-r--r-- 1 user group 4096 Dec 22 database.db-walCheck if .db-shm is readable but not writable:
# Test specific file permissions
ls -l /path/to/database.db-shm
# Check if process can write
if [ -w /path/to/database.db-shm ]; then
echo "File is writable"
else
echo "File is NOT writable - this is likely the problem"
fi
# Check directory permissions too
ls -ld /path/to/Test with stat command for detailed information:
# Get detailed permission info
stat /path/to/database.db-shm
stat /path/to/
# Look at "Access" permissions in outputIf the .db-shm file exists but has read-only permissions, make it writable for the process that needs to access it.
Make .db-shm writable:
# Add write permission to the shared memory file
chmod 664 /path/to/database.db-shm
# Verify the change
ls -l /path/to/database.db-shm
# Should now show: -rw-rw-r-- (0664)Make all database files consistent:
# Ensure consistent permissions on all WAL-related files
chmod 664 /path/to/database.db
chmod 664 /path/to/database.db-wal
chmod 664 /path/to/database.db-shm
# Verify all files
ls -l /path/to/database.db*If ownership is the issue:
# Check current ownership
ls -l /path/to/database.db*
# Change ownership to the user running the application
sudo chown myuser:mygroup /path/to/database.db*
# Also set directory ownership if needed
sudo chown myuser:mygroup /path/to/
# Verify
ls -l /path/to/For application running as specific user (e.g., web server):
# For nginx/www-data user
sudo chown www-data:www-data /path/to/database.db*
sudo chmod 664 /path/to/database.db*
sudo chmod 755 /path/to/
# Verify the www-data user can access
su - www-data -s /bin/bash -c "ls -la /path/to/database.db"Check in application code if permissions need fixing:
import os
import stat
db_path = '/path/to/database.db'
shm_path = db_path + '-shm'
# Check if .db-shm is readable but not writable
if os.path.exists(shm_path):
st = os.stat(shm_path)
mode = stat.filemode(st.st_mode)
print(f"Current permissions: {mode}")
# Check if writable
if not os.access(shm_path, os.W_OK):
print("WARNING: .db-shm is not writable!")
print("Try: chmod 664", shm_path)The directory containing the database must have write permissions so SQLite can create or modify the .db-shm file if needed.
Check directory permissions:
# Check the directory's permissions
ls -ld /path/to/
# Good: drwxr-xr-x or drwxrwxr-x (755 or 775)
# Bad: dr-xr-xr-x (555 - read-only)
# Check if directory is writable by current user
if [ -w /path/to/ ]; then
echo "Directory is writable"
else
echo "Directory is NOT writable"
fiFix directory permissions:
# Add write permission to the directory
chmod 755 /path/to/
# Or with group write access
chmod 775 /path/to/
# Verify
ls -ld /path/to/Handle parent directory issues:
# Sometimes parent directories need permissions too
# Check each level
ls -ld /path/
ls -ld /path/to/
# Make sure each level is accessible and writable
chmod 755 /path/
chmod 755 /path/to/If directory is on read-only mount:
# Check if filesystem is mounted read-only
mount | grep /path/
# Look for "ro" flag in output
# If found, the mount is read-only
# Remount as read-write (if you have permissions)
sudo mount -o remount,rw /path/
# Verify
mount | grep /path/If the database must be deployed to a read-only location, convert it to DELETE journal mode which doesn't require shared memory files. This is the most permanent solution for read-only deployments.
IMPORTANT: Perform this conversion on a writable copy of the database first, then deploy the converted version.
Convert using SQLite CLI:
# First, make a backup
cp /path/to/database.db /path/to/database.db.backup
# Convert to DELETE mode (requires write access)
sqlite3 /path/to/database.db "PRAGMA journal_mode=DELETE;"
# Output should show: delete
# Verify the conversion
sqlite3 /path/to/database.db "PRAGMA journal_mode;"
# Checkpoint and cleanup
sqlite3 /path/to/database.db "PRAGMA wal_checkpoint(TRUNCATE);"
sqlite3 /path/to/database.db "VACUUM;"
# Remove the WAL files (they're no longer needed)
rm -f /path/to/database.db-wal
rm -f /path/to/database.db-shmConvert using Python:
import sqlite3
import os
db_path = '/path/to/database.db'
# Create backup first
import shutil
shutil.copy(db_path, db_path + '.backup')
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Convert to DELETE mode
cursor.execute("PRAGMA journal_mode=DELETE")
mode = cursor.fetchone()[0]
print(f"Journal mode: {mode}")
# Checkpoint to clean up WAL
cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)")
result = cursor.fetchone()
print(f"Checkpoint: busy={result[0]}, log_size={result[1]}, checkpointed={result[2]}")
# Optimize database
cursor.execute("VACUUM")
print("VACUUM complete")
conn.close()
# Remove WAL files
for ext in ['-wal', '-shm']:
path = db_path + ext
if os.path.exists(path):
os.remove(path)
print(f"Removed {path}")
print("Database converted successfully")
except Exception as e:
print(f"Error: {e}")
# Restore backup on failure
shutil.copy(db_path + '.backup', db_path)Convert using Node.js (better-sqlite3):
const Database = require('better-sqlite3');
const fs = require('fs');
const path = require('path');
const dbPath = '/path/to/database.db';
// Backup first
fs.copyFileSync(dbPath, dbPath + '.backup');
try {
const db = new Database(dbPath);
// Convert to DELETE mode
const mode = db.pragma('journal_mode = DELETE');
console.log('Journal mode:', mode);
// Checkpoint
const checkpoint = db.pragma('wal_checkpoint(TRUNCATE)');
console.log('Checkpoint:', checkpoint);
// Vacuum
db.pragma('vacuum');
console.log('VACUUM complete');
db.close();
// Remove WAL files
['-wal', '-shm'].forEach(ext => {
const p = dbPath + ext;
if (fs.existsSync(p)) {
fs.unlinkSync(p);
console.log('Removed ' + path.basename(p));
}
});
console.log('Database converted successfully');
} catch (error) {
console.error('Error:', error);
fs.copyFileSync(dbPath + '.backup', dbPath);
}After conversion, the database can be safely deployed to read-only media as a single .db file.
If the .db-shm file is corrupted or has incorrect permissions, deleting it and allowing SQLite to recreate it can fix the issue.
WARNING: Only do this when no other connections to the database exist.
Delete and recreate the shared memory file:
# First, ensure no processes are using the database
# Use lsof to check
lsof | grep database.db
# If database is not in use, delete the .db-shm file
rm -f /path/to/database.db-shm
# SQLite will recreate it with proper permissions when next opened
# Try opening the database
sqlite3 /path/to/database.db "PRAGMA integrity_check;"
# Check if .db-shm was recreated
ls -l /path/to/database.db-shmBefore deleting, verify no processes hold locks:
# Check which processes have database file open
lsof /path/to/database.db
# If processes are listed, stop them first
# Example for Node.js app
pkill -f "node app.js"
# Or graceful restart for web server
sudo systemctl restart nginx
sudo systemctl restart apache2
# Then delete and recreate
rm -f /path/to/database.db-shmScript to safely handle .db-shm recreation:
import os
import sqlite3
import time
db_path = '/path/to/database.db'
shm_path = db_path + '-shm'
# Check if process is using the database
def check_process_lock(db_path):
try:
conn = sqlite3.connect(db_path, timeout=1.0)
conn.execute("PRAGMA query_only=1")
conn.close()
return False
except sqlite3.OperationalError as e:
if 'database is locked' in str(e):
return True
raise
# If safe, delete and recreate
try:
if check_process_lock(db_path):
print("Database is locked - wait for processes to finish")
time.sleep(5)
# Delete the .db-shm file
if os.path.exists(shm_path):
os.remove(shm_path)
print(f"Deleted {shm_path}")
# Open database to trigger recreation
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA integrity_check")
conn.close()
# Verify recreation
if os.path.exists(shm_path):
st = os.stat(shm_path)
mode = oct(st.st_mode)[-3:]
print(f".db-shm recreated with permissions {mode}")
except Exception as e:
print(f"Error: {e}")On systems with mandatory access control (MAC), SELinux or AppArmor may specifically deny write access to the .db-shm file even with correct Unix 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*
# Look for context like:
# unconfined_u:object_r:user_home_t:s0 database.db
# unconfined_u:object_r:user_home_t:s0 database.db-shmFix SELinux context for database files:
# Set appropriate context for web application
# For Apache/httpd
sudo chcon -t httpd_sys_rw_content_t /path/to/database.db*
sudo chcon -t httpd_sys_rw_content_t /path/to/
# For systemd service
sudo chcon -t admin_home_t /path/to/database.db*
# Or use semanage for persistent rules
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/path/to/database(/.*)?"
sudo restorecon -R /path/to/
# Verify changes
ls -Z /path/to/database.db*Check for SELinux denials:
# Check audit logs for recent SELinux denials
sudo ausearch -m avc -ts recent | grep database
# If denials found, generate policy
sudo ausearch -m avc -ts recent | audit2allow -M database_policy
sudo semodule -i database_policy.pp
# List active policies
sudo semodule -l | grep databaseCheck AppArmor status:
# Check if AppArmor is active
sudo aa-status
# Check your application's profile
sudo aa-status | grep "your-app"
# See denials
sudo tail -f /var/log/audit/audit.log | grep APPARMORModify AppArmor profile:
# Edit the profile (example for Node.js)
sudo nano /etc/apparmor.d/usr.bin.node
# Add these lines to allow database access:
# /path/to/database.db rw,
# /path/to/database.db-wal rw,
# /path/to/database.db-shm rw,
# /path/to/ r,
# Reload the profile
sudo apparmor_parser -r /etc/apparmor.d/usr.bin.node
# For testing, use complain mode (logs but doesn't enforce)
sudo aa-complain /usr/bin/node
# Check for denials
sudo tail -f /var/log/audit/audit.logIn Docker and other containerized environments, ensure the database directory has correct permissions and ownership matching the container user.
Docker with volume mounts:
# Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Create database directory with proper permissions
RUN mkdir -p /app/data && chmod 755 /app/data
# Copy pre-converted database (DELETE mode, not WAL)
# COPY database.db /app/data/
# RUN chmod 644 /app/data/database.db
# Run as non-root user
RUN useradd -m dbuser
USER dbuser
CMD ["node", "server.js"]Docker Compose with persistent volume:
version: '3.8'
services:
app:
build: .
volumes:
# Mount database directory as volume
- ./database:/app/data
environment:
- DATABASE_PATH=/app/data/database.db
user: "node" # Run as specific userFix permissions from host:
# Before running container, ensure database directory on host is writable
chmod 755 ./database
chmod 644 ./database/database.db
# Or use Docker volume with specific permissions
docker volume create --opt type=tmpfs --opt device=tmpfs --opt o=rw my_db_volKubernetes with proper permissions:
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 permissions match pod user
volumes:
- name: database-storage
persistentVolumeClaim:
claimName: database-pvcSystemd service with StateDirectory:
[Unit]
Description=Database Application
After=network.target
[Service]
Type=simple
User=dbapp
Group=dbapp
# Use StateDirectory to auto-create with correct permissions
StateDirectory=dbapp
# This creates /var/lib/dbapp owned by dbapp:dbapp
WorkingDirectory=/opt/dbapp
Environment="DATABASE_PATH=/var/lib/dbapp/database.db"
ExecStart=/usr/bin/node server.js
[Install]
WantedBy=multi-user.targetSQLite WAL Locking Mechanism:
Understanding how SQLite's WAL locking works helps explain why write access is needed even for read-only connections.
SQLite uses specific bytes within the .db-shm shared memory file to implement locks:
- Bytes 0-7: Header (version, magic bytes)
- Byte 8: Lock index
- Bytes 9-15: Reader slots (each reader claims a slot to indicate it's active)
Even a pure reader must atomically write to its assigned slot to claim it, which requires write access.
Lock Types in WAL Mode:
1. READER Lock: Claimed by reading transactions. Requires atomic write to .db-shm.
2. WRITER Lock: Claimed by writing transactions
3. CKPT Lock: Claimed by checkpointing process
The SQLITE_READONLY_CANTLOCK error specifically means: "I tried to claim a READER lock but can't write to the .db-shm file."
Why Not Use Read-Only Mode?
SQLite's documentation states: "It is not practical to open read-only WAL databases." This is because:
1. The shared memory file must exist for WAL coordination
2. Creating the .db-shm requires write access to the directory
3. Updating reader slots requires atomic writes to .db-shm
4. No truly read-only mechanism exists that's safe for concurrent access
The only safe way to deploy a SQLite database read-only is to:
1. Convert to DELETE journal mode first
2. Remove .db-wal and .db-shm files
3. Deploy only the single .db file
4. Open with read-only flags
Comparison with Other Error Codes:
- SQLITE_READONLY_RECOVERY: Occurs during WAL recovery (replay of uncommitted changes). Requires write for recovery.
- SQLITE_READONLY_CANTLOCK: Occurs before recovery (can't even acquire the basic read lock). Indicates fundamental permission issue with .db-shm.
- SQLITE_READONLY_DBMOVED: Database file was moved to another location (different filesystem). Lock file invalid.
- SQLITE_READONLY_DIRECTORY: Directory containing database is read-only.
- SQLITE_READONLY_CANTINIT: Can't initialize WAL shared memory file.
Debugging WAL Lock Issues:
Enable SQLite debug output:
import sqlite3
import logging
# Check SQLite version and compile options
conn = sqlite3.connect(':memory:')
version = sqlite3.version
print(f"SQLite version: {version}")
# Check compile options (requires appropriate SQLite build)
cursor = conn.cursor()
cursor.execute("PRAGMA compile_options")
for row in cursor.fetchall():
if 'WAL' in row[0]:
print(f"Compile option: {row[0]}")Platform-Specific Notes:
- Linux with NFS: Shared memory locking may fail. Use local storage.
- Windows with OneDrive/Dropbox: Real-time sync can interfere with WAL locks.
- macOS with iCloud sync: Similar sync issues. Use local Documents folder.
- Docker on Windows/Mac: Host filesystem behavior can cause lock failures.
- Mobile platforms (iOS/Android): WAL on read-only app bundles always fails.
Performance Trade-offs:
WAL mode vs DELETE mode for permission-sensitive deployments:
| Aspect | WAL Mode | DELETE Mode |
|--------|----------|-------------|
| Permissions required | Write access to .db-shm (complex) | Write access to directory during transaction (simpler) |
| Read-only deployment | Impossible/impractical | Fully supported |
| Concurrent readers | Excellent (no blocking) | Poor (readers block writers) |
| Storage files | 3 files (.db, .db-wal, .db-shm) | 1 file (.db) or 2 during transaction |
| Recovery after crash | WAL replay automatic | Journal rollback automatic |
When to Convert to DELETE Mode:
1. Database deployed to read-only media or mountpoint
2. Running in restricted containers or sandboxes
3. Need database as single portable file
4. Deploying to iOS/Android app bundles
5. Using network filesystems where WAL locks fail
6. Permission complexity outweighs concurrency benefits
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_READONLY_RECOVERY: WAL mode database cannot be modified
How to fix "SQLITE_READONLY_RECOVERY: WAL mode database cannot be modified" in SQLite