Essential PRAGMA Settings for Production
Write-Ahead Logging and Synchronous Mode
The single most important change for production is PRAGMA journal_mode=WAL;. The default rollback journal blocks all readers during a write. Write-ahead logging lets readers proceed from a snapshot while writes append to a separate -wal file; a shared-memory file (-shm) coordinates access. Readers never block writers, writers never block readers, and only writers block other writers. This is the write-ahead logging mode you need for any multi-threaded application or concurrent access scenario.
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
With WAL enabled, synchronous=NORMAL is safe and much faster than the default FULL. FULL calls fsync after every commit, guaranteeing durability even if the OS crashes mid-write. NORMAL relies on the WAL protocol itself for crash safety and only syncs at checkpoints, cutting write latency by 50-80% on most systems. Do not use synchronous=OFF unless you accept data loss on power failure. Set journal_mode before synchronous; the latter's behavior depends on the former.
Page Size, Cache Size, and Memory Mapping
Page size must match your workload and filesystem block size. The default 4096 bytes works well for small rows and random access. Use 8192 or 16384 for wide rows or bulk scans, but set it before inserting data; changing page size later requires a full vacuum and rebuild. Check the current value with PRAGMA page_size; and set it only on a new database:
PRAGMA page_size=8192;
VACUUM;
The page cache defaults to -2000 (negative means pages, roughly 2MB with 4KB pages). For read-heavy workloads, increase to -64000 (256MB) or more if you have RAM to spare. Check the current setting with PRAGMA cache_size; and adjust per connection:
PRAGMA cache_size=-64000;
Memory-mapped I/O with mmap_size can eliminate kernel copies for read-heavy access patterns. A 256MB ceiling is safe on 64-bit systems; avoid on 32-bit or if the database exceeds a few gigabytes. The default is 0 (disabled).
PRAGMA mmap_size=268435456;
Use temp_store=MEMORY to keep temporary tables and indexes in RAM instead of spilling to disk, helpful for complex queries with large intermediate results.
Locking Mode and Busy Timeout
Do not set locking_mode=EXCLUSIVE unless your application is the only process that will ever open the database. Exclusive mode holds a lock indefinitely, blocking backups and command-line tools. The default NORMAL releases locks between transactions and is correct for almost every production scenario.
Set busy_timeout to at least 5000 milliseconds. When a write transaction conflicts with another, SQLite retries for this duration before returning SQLITE_BUSY. The default 0 means fail immediately, which causes spurious errors in multi-threaded web applications:
PRAGMA busy_timeout=5000;
A complete startup script for a production connection looks like this:
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-64000;
PRAGMA busy_timeout=5000;
PRAGMA temp_store=MEMORY;
Index Design and Query Plan Analysis
Creating Effective Indexes
Column order in a multi-column index determines what queries can use it. Put equality conditions first, then range or sort columns. An index on (status, created_at) accelerates WHERE status='active' ORDER BY created_at, but an index on (created_at, status) cannot because the range scan on created_at comes first.
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
Partial indexes reduce size and improve performance when you only query a subset of rows. If 95% of users are active and you never query inactive ones, index only the active set:
CREATE INDEX idx_active_users_email ON users(email) WHERE active=1;
Expression indexes handle computed columns or JSON extraction without storing redundant data:
CREATE INDEX idx_data_field ON events(json_extract(data, '$.user_id'));
After bulk inserts or schema changes, run ANALYZE; to update statistics. The query planner uses these to choose indexes; stale statistics cause table scans even when an index exists.
Reading EXPLAIN QUERY PLAN Output
Always check EXPLAIN QUERY PLAN before deploying a query. Look for SCAN TABLE (full table scan, slow) versus SEARCH TABLE USING INDEX (index lookup, fast):
EXPLAIN QUERY PLAN
SELECT email, name FROM users WHERE status='active' ORDER BY created_at LIMIT 10;
Output showing SCAN TABLE users means no suitable index. Add one, re-run the explain, and confirm SEARCH TABLE users USING INDEX idx_users_status_created. The difference between a 200ms table scan and a 2ms index seek is whether your application handles 10 requests per second or 1000.
Covering Indexes and Expression Indexes
A covering index includes all columns in the SELECT list, eliminating the table lookup. If you frequently query SELECT email, name FROM users WHERE user_id=?, create an index on (user_id, email, name). The output will show USING COVERING INDEX, confirming SQLite read everything from the index without touching the table:
CREATE INDEX idx_user_id_email_name ON users(user_id, email, name);
This pattern is especially effective for narrow queries on wide tables. The index is larger, but read performance doubles or triples because you avoid random disk seeks to the main table. Balance index size against query frequency; a 10GB table with a 2GB covering index is worthwhile if that query runs 10,000 times per day.
Write Performance and Concurrency
Transaction Batching
A single INSERT outside a transaction runs at roughly 150 operations per second because SQLite opens and commits a transaction for every statement. Wrap 1000 inserts in BEGIN and COMMIT and throughput jumps to 50,000 per second:
-- Slow: ~150 inserts/sec
INSERT INTO events (user_id, action) VALUES (123, 'login');
-- Fast: ~50,000 inserts/sec
BEGIN;
INSERT INTO events (user_id, action) VALUES (123, 'login');
INSERT INTO events (user_id, action) VALUES (124, 'logout');
-- ... 998 more
COMMIT;
Batch size trades latency for throughput. Commit every 1000-5000 rows for background jobs; every 100-500 for user-facing writes where you need sub-second response. Never commit every row unless you need durability guarantees that strong.
WAL Checkpointing
The WAL file grows as writes accumulate. Every 1000 pages (the wal_autocheckpoint default), SQLite merges WAL contents back into the main database file. Check the current threshold with PRAGMA wal_autocheckpoint; and adjust if needed:
PRAGMA wal_autocheckpoint=2000;
Force a checkpoint manually with PRAGMA wal_checkpoint(TRUNCATE); during low-traffic windows. The return value shows pages checkpointed and pages remaining. TRUNCATE mode resets the WAL file to zero bytes; PASSIVE (the default) only checkpoints if no readers are active, so it may leave data in the WAL. Use RESTART to move the WAL write point back to the beginning without truncating, a middle ground for active databases.
Handling SQLITE_BUSY Errors
Even with busy_timeout set, you will occasionally see SQLITE_BUSY under heavy concurrent writes. Retry with exponential backoff instead of failing the request. In Python with the sqlite3 module:
import sqlite3
import time
for attempt in range(5):
try:
conn.execute("INSERT INTO logs (message) VALUES (?)", (msg,))
conn.commit()
break
except sqlite3.OperationalError as e:
if 'locked' in str(e).lower() and attempt < 4:
time.sleep(2 ** attempt * 0.1)
else:
raise
For write-heavy workloads, use BEGIN IMMEDIATE instead of plain BEGIN to acquire the write lock upfront. This fails fast if another transaction holds the lock, rather than succeeding at BEGIN and failing later at the first INSERT.
Read-Heavy Optimization Patterns
Query Caching in Application Layer
SQLite has no query result cache; every SELECT hits disk or the page cache. Cache frequently accessed rows in your application. A simple in-memory dictionary with timestamps handles 90% of read-heavy workloads:
import time
cache = {}
CACHE_TTL = 300 # 5 minutes
def get_user(user_id):
now = time.time()
if user_id in cache:
row, timestamp = cache[user_id]
if now - timestamp < CACHE_TTL:
return row
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
cache[user_id] = (row, now)
return row
Invalidate entries on write: del cache[user_id] after UPDATE users WHERE id=?. For distributed systems, use Redis or memcached instead of an in-process dictionary. Cache hit rates above 80% cut database load by an order of magnitude.
Read-Only Connections and Immutable Flag
Open read-only connections with the URI parameter mode=ro to prevent accidental writes and allow multiple processes to share the same database file without locking:
import sqlite3
conn = sqlite3.connect('file:data.db?mode=ro', uri=True)
Alternatively, set PRAGMA query_only=1; after opening a normal connection. Any INSERT, UPDATE, or DELETE will fail with an error instead of silently acquiring a write lock.
For databases that never change (configuration data, static reference tables, ML model embeddings) use the immutable=1 flag. SQLite skips all locking and assumes the file will not change:
conn = sqlite3.connect('file:config.db?immutable=1', uri=True)
This is safe only if you guarantee no process will write to the file. The performance gain is substantial: no lock contention, no shared-memory overhead, and the OS can cache pages aggressively across processes.
Temporary Tables for Complex Joins
Multi-join queries that scan the same large table repeatedly benefit from an intermediate temporary table. Temporary tables exist per-connection, live in memory (if temp_store=MEMORY), and disappear when the connection closes. Build the intermediate result once, then query it multiple times:
CREATE TEMP TABLE recent_orders AS
SELECT o.order_id, o.user_id, o.total, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > date('now', '-7 days');
SELECT email, SUM(total) FROM recent_orders GROUP BY email;
SELECT user_id, COUNT(*) FROM recent_orders GROUP BY user_id;
The first CREATE TEMP TABLE statement performs the expensive join and date filter once. Subsequent queries against recent_orders run in milliseconds instead of seconds because they avoid re-scanning millions of rows. Drop the temp table explicitly with DROP TABLE recent_orders; or let it vanish when the connection closes.
Vacuum, Analyze, and Integrity Checks
When and How to Run VACUUM
After deleting large amounts of data, SQLite marks pages as free but does not return disk space to the OS. VACUUM; rebuilds the entire database file, defragments pages, and reclaims space. It requires free disk space equal to the current database size and locks the database for the duration: seconds for a 100MB file, minutes for 10GB.
VACUUM;
Success produces no output. Run VACUUM during maintenance windows, not during peak traffic. SQLite 3.27 added VACUUM INTO, which writes a compacted copy to a new file without locking the original:
VACUUM INTO 'optimized.db';
For gradual space reclamation, enable incremental auto-vacuum mode before inserting data:
PRAGMA auto_vacuum=INCREMENTAL;
VACUUM;
Then call PRAGMA incremental_vacuum(N); to reclaim up to N pages at a time. This spreads the cost across multiple operations instead of one blocking rebuild. Check PRAGMA freelist_count; to see how many pages are available for reclamation.
Updating Query Planner Statistics with ANALYZE
The query planner relies on table and index statistics stored in the sqlite_stat1 table. After schema changes, bulk imports, or when query plans degrade, run ANALYZE; to refresh these statistics:
ANALYZE;
This scans all tables and indexes, updating row counts and cardinality estimates. For large databases, analyze a specific table instead of the entire schema:
ANALYZE events;
The ANALYZE command takes seconds on a million-row table, minutes on a billion-row one. Schedule it after nightly ETL jobs or major data loads. Stale statistics cause the planner to choose table scans over indexes or pick the wrong index in a multi-index query, turning a 10ms query into a 2-second one.
Detecting Corruption with PRAGMA integrity_check
Run PRAGMA integrity_check; after crashes, before backups, and in CI pipelines. It scans every page, verifies checksums, and checks foreign key constraints. A healthy database returns a single row:
PRAGMA integrity_check;
-- ok
Corruption produces specific error messages:
PRAGMA integrity_check;
-- row 47 missing from index idx_users_email
-- page 1234 is never used
For faster checks, use PRAGMA quick_check;, which skips some validation but catches most corruption. Run integrity_check on a copy of the database file if you cannot afford the I/O load on the live system. If corruption is detected, restore from backup immediately; attempting to repair with VACUUM or REINDEX may spread the damage. Corruption usually indicates hardware issues, power loss during a write, or filesystem bugs. Investigate the root cause before putting the database back into production.
Backup Strategies Without Downtime
Online Backup API and .backup Command
The online backup API copies a database page by page while allowing concurrent reads and writes. In the sqlite3 CLI, use .backup to create a consistent snapshot:
sqlite3 database.db ".backup backup.db"
This runs in the foreground, showing no output on success. In Python, the backup() method on a connection object performs the same operation:
import sqlite3
src = sqlite3.connect('database.db')
dest = sqlite3.connect('backup.db')
src.backup(dest)
dest.close()
src.close()
The backup proceeds in steps, yielding to writers between chunks. A 10GB database takes 30-60 seconds; writes continue throughout. The destination file is always consistent, even if the source is modified during the copy. This is the safest method for live databases and works in any journal mode.
Copying WAL-Mode Databases Safely
Simply copying database.db with cp in WAL mode produces an incomplete backup. The -wal file contains uncommitted changes, and the -shm file holds shared-memory state. To copy manually, first checkpoint and truncate the WAL:
sqlite3 database.db "PRAGMA wal_checkpoint(TRUNCATE);"
cp database.db backup.db
wal_checkpoint(TRUNCATE) merges all WAL content into the main file and resets the WAL to zero bytes. The return value is three integers: 0|pages_checkpointed|pages_remaining. If pages_remaining is non-zero, active readers prevented a full checkpoint; retry after they close or accept that the backup will need the WAL file. To copy all three files atomically, pause writes, copy database.db, database.db-wal, and database.db-shm, then resume. The online backup API is simpler and safer.
Continuous Replication with Litestream
Litestream (https://litestream.io) replicates SQLite databases to S3, Azure Blob Storage, or local disk by streaming WAL changes in near real-time. Install the binary and run:
litestream replicate database.db s3://bucket/db
This watches for WAL writes, uploads new pages every few seconds, and handles checkpointing automatically. Restore to any point in time with litestream restore -o restored.db s3://bucket/db. For production, run Litestream as a systemd service:
[Unit]
Description=Litestream Replication
After=network.target
[Service]
ExecStart=/usr/local/bin/litestream replicate /var/lib/app/database.db s3://bucket/db
Restart=always
[Install]
WantedBy=multi-user.target
Litestream eliminates the need for cron-based backups and provides disaster recovery with minimal overhead. For distributed SQLite across multiple nodes, LiteFS (Fly.io) offers FUSE-based replication, though it remains experimental as of 2026 and requires careful evaluation before production use.
Debugging Slow Queries and Lock Contention
Enabling Query Logging
SQLite has no built-in slow query log. Your application must time queries explicitly. In Python, wrap execute() calls with timing:
import time
start = time.time()
cursor.execute("SELECT * FROM events WHERE user_id=?", (123,))
elapsed = time.time() - start
if elapsed > 0.1:
print(f'Slow query ({elapsed:.3f}s): SELECT * FROM events WHERE user_id=?')
Log queries over a threshold (100ms is reasonable) to identify hot spots. In high-traffic applications, sample 1% of queries to avoid log volume overwhelming your monitoring. Structured logging with query parameters and execution time lets you aggregate and analyze patterns: which tables scan most, which indexes are missing, which queries degrade under load.
Profiling with .timer and .eqp
In the sqlite3 CLI, enable .timer on to print execution time after every statement:
.timer on
SELECT COUNT(*) FROM orders WHERE status='pending';
-- Run Time: real 0.042 user 0.038 system 0.003
Use .eqp on to automatically run EXPLAIN QUERY PLAN before each query. This shows whether indexes are used without typing EXPLAIN manually:
.eqp on
SELECT email FROM users WHERE status='active';
-- QUERY PLAN
-- `--SCAN TABLE users
For detailed plans including subqueries and CTEs, use .eqp full. The output reveals nested loops, temporary tables, and index choices. A SCAN TABLE line in the plan means a full table scan; add an index and re-run to confirm SEARCH TABLE ... USING INDEX appears. The difference in run time confirms the optimization worked.
Identifying Lock Bottlenecks
If writes return SQLITE_BUSY or hang, check PRAGMA busy_timeout; first. It should be at least 5000 milliseconds. Ensure journal_mode=WAL; the default rollback journal blocks readers during writes, causing contention that WAL eliminates. Reduce transaction duration by committing every 500-1000 rows instead of holding a transaction open for minutes. Use BEGIN IMMEDIATE for writes to acquire the lock upfront:
BEGIN IMMEDIATE;
INSERT INTO logs (message) VALUES ('test');
COMMIT;
Check build-time options with PRAGMA compile_options; to confirm THREADSAFE=1 (required for multi-threaded use) and identify any OMIT_* or MAX_* limits. List all attached databases with PRAGMA database_list; to verify you are querying the expected file. If lock contention persists despite WAL mode and proper timeouts, profile transaction length and batch size; holding a write lock for seconds while processing thousands of rows will always block other writers.
Production Checklist and Monitoring
Before deploying SQLite in production, verify each item on this checklist:
PRAGMA journal_mode=WAL;,synchronous=NORMAL;,busy_timeout=5000;set on every connection.PRAGMA page_size=4096;configured before first write; cannot change later without rebuild.PRAGMA cache_size=-64000;or higher for read-heavy workloads with available RAM.- Indexes on all columns in
WHERE,JOIN, andORDER BYclauses; verify withEXPLAIN QUERY PLAN. - All inserts and updates wrapped in explicit transactions; commit every 500-5000 rows.
ANALYZE;executed after schema changes, bulk imports, and monthly as maintenance.PRAGMA integrity_check;run weekly or after crashes; restore from backup if corruption detected.- Backups via online API,
.backupcommand, or Litestream; test restores quarterly. - Query timing logged in application; alert on queries exceeding 100ms threshold.
- Database and WAL file sizes monitored; large WAL indicates checkpoint failures.
Check WAL size with ls -lh database.db-wal. If it exceeds 100MB, checkpoints are not completing; verify no long-running readers are blocking them. Monitor fragmentation with PRAGMA page_count; (total pages) and PRAGMA freelist_count; (free pages). A freelist over 10% of total pages suggests running VACUUM during the next maintenance window.
For deeper coverage of SQLite application patterns, see Mastering Python and SQLite: From Basics to Advanced Applications. The official PRAGMA documentation remains the authoritative reference for all configuration options and their interactions.
Frequently Asked Questions
Is SQLite fast enough for production web applications?
Yes, for read-heavy workloads or moderate write rates (under 1000 writes/sec). With WAL mode, proper indexing, and transaction batching, SQLite handles tens of thousands of reads per second on commodity hardware. It excels in embedded systems, edge deployments, and single-server applications where operational simplicity matters more than horizontal scaling. If you need distributed writes across multiple servers, choose PostgreSQL or MySQL instead.
When should I use WAL mode instead of DELETE journal mode?
Always use WAL mode for any application with concurrent access. The default DELETE mode blocks all readers during writes, causing contention in multi-threaded web servers or applications with background workers. WAL mode allows readers and a single writer to proceed simultaneously, eliminates most lock contention, and improves write throughput. The only reason to avoid WAL is on filesystems that do not support shared memory or when you need compatibility with SQLite versions older than 3.7.0.
How do I fix SQLITE_BUSY errors in a multi-threaded application?
Set PRAGMA busy_timeout=5000; or higher to make SQLite retry for several seconds before failing. Enable WAL mode with PRAGMA journal_mode=WAL; so readers do not block writers. Use BEGIN IMMEDIATE for write transactions to acquire the lock upfront. Reduce transaction duration by committing more frequently. If errors persist, implement exponential backoff retries in your application code and profile transaction length to find long-running operations holding locks.
What is the maximum database size SQLite can handle?
The theoretical limit is 281 terabytes, but practical limits depend on filesystem, available RAM for the page cache, and query patterns. Databases up to 100GB work well with proper indexing and hardware. Beyond that, vacuum and integrity checks take hours, backups become unwieldy, and you should evaluate whether SQLite is still the right tool. Most production SQLite deployments stay under 10GB; if you regularly exceed that, consider PostgreSQL for better tooling, replication, and operational support.
How do I back up a SQLite database without stopping writes?
Use the online backup API: .backup newfile.db in the CLI or src.backup(dest) in Python. This copies the database page by page while allowing concurrent writes, producing a consistent snapshot. Alternatively, run Litestream to continuously replicate WAL changes to S3 or another backend. Avoid copying the database file directly with cp in WAL mode unless you first checkpoint with PRAGMA wal_checkpoint(TRUNCATE); and accept brief write pauses.