WAL Mode Will Save Your Ass (Then Stab You in the Back)
SQLite's default journal mode is complete garbage. Every single write blocks all the readers and syncs to disk like three times. WAL mode fixes most of this bullshit by writing to a separate log file instead.
PRAGMA journal_mode = WAL;
But WAL mode isn't magic - it comes with its own special ways to fuck you over.
Docker for Mac silently breaks everything: Spent a whole week wondering why our dev environment was dog slow compared to production. Turns out WAL mode just... doesn't work on Docker for Mac. Something about the osxfs filesystem not supporting shared memory properly, so SQLite 3.39 quietly falls back to DELETE journal mode. No errors, no warnings, no "hey your WAL mode didn't actually enable" - just mystery slowness. Found this buried in some random SQLite forum post.
Your backup script is probably broken: WAL creates three files instead of one: the main .db
file plus .db-wal
and .db-shm
. Our backup script cheerfully copied just the .db
file for like six months. Nobody noticed until we needed to restore and discovered half our data was sitting in the uncopied WAL file. That was a fun Monday morning.
Synchronous=FULL is Database Performance Cancer
The default synchronous=FULL
waits for the OS to pinky-promise that every single byte made it to disk before continuing. This makes everything crawl.
PRAGMA synchronous = NORMAL;
In WAL mode, NORMAL is usually safe enough. Yeah, you might lose a few seconds of data if someone trips over the power cord, but your database won't corrupt. Unless you're processing financial transactions or medical records, NORMAL is probably fine.
Real performance difference: Last month I had a database doing like 30-60 writes/sec in FULL mode - honestly painful to watch. Switched to NORMAL and we hit somewhere around 3K writes/sec. Not sure the exact numbers but it went from "users complaining constantly" to "nobody gives a shit anymore."
When you need FULL mode: If losing the last few form submissions during a power outage would end your business, stick with FULL. But most web apps can handle losing a few seconds of data rather than having everything crawl.
Memory Mapping: Great Until Your App Gets OOMKilled
PRAGMA mmap_size = 268435456; -- 256MB
Memory mapping lets SQLite read directly from RAM instead of making system calls. Works great until you run out of memory and everything swaps.
The Kubernetes OOMKill disaster: Set mmap_size to 2GB on a 4GB container limit and the Linux kernel just murdered our app when it tried to allocate memory for other stuff. Exit code 137 - OOMKilled. Happened on three different projects before I learned to never mmap more than like 50% of available memory.
Platform bullshit: Linux handles memory mapping well. macOS has weird virtual memory behavior that makes large memory maps unpredictable. Windows... just don't. Seriously.
Individual INSERTs Will Ruin Your Life
Here's the thing that kills SQLite performance more than anything else: doing one INSERT at a time. Each individual statement is basically its own transaction that sits around waiting for the disk to acknowledge it wrote the data.
Had this import script that was taking fucking forever - I think it was like 4-5 hours for maybe 200K records? Turns out every INSERT was its own transaction, so SQLite was syncing to disk 200K times. Added BEGIN/COMMIT around the whole batch and it dropped to like 8 minutes. Felt pretty stupid.
-- This will kill your weekend
for row in data:
INSERT INTO users (name, email) VALUES (?, ?);
-- Do this instead
BEGIN;
for row in data:
INSERT INTO users (name, email) VALUES (?, ?);
COMMIT;
Don't batch everything though: Tried batching like 400K inserts in one transaction and the whole database locked up for god knows how long. Everything else got "database is locked" and users started bitching. Now I do batches of maybe 5-10K records - seems to work without pissing anyone off.
"Database is locked" hell: This error message is useless. Could be a hanging transaction, a connection that never closed, or WAL mode shitting itself. Could be anything. Good luck debugging it.
SQLite's Default Cache is an Insult to Modern Hardware
2MB of cache. In 2025. When my phone has 8GB of RAM.
PRAGMA cache_size = -64000; -- 64MB cache (negative = KB)
PRAGMA temp_store = memory; -- Keep temp tables in RAM
Had this one query that was consistently taking 45+ seconds. Spent days looking at indexes, query plans, all kinds of shit. Finally realized the cache was so small that SQLite was constantly hitting disk for data that should've been in memory. Bumped cache to 64MB and boom - same query in 2 seconds. I wanted to punch something.
Cache size confusion: Positive numbers are pages, negative numbers are KB. So cache_size = 1000
gives you maybe 4MB depending on page size, but cache_size = -64000
gives you exactly 64MB. Whoever designed this API was drunk.
Temp storage matters: Complex queries with GROUP BY
or ORDER BY
create temporary indexes. By default SQLite writes these to disk, which is slow as hell. temp_store = memory
keeps them in RAM. Just watch your memory usage or the kernel will OOMKill you.
WAL Checkpointing: The Silent Database Killer
PRAGMA wal_autocheckpoint = 1000; -- Default, usually fine
PRAGMA wal_checkpoint(TRUNCATE); -- Manual cleanup
WAL mode keeps appending to the .db-wal
file until a checkpoint moves changes to the main database. Usually this happens automatically every 1000 pages, but sometimes it doesn't and your WAL file grows to ridiculous sizes.
WAL file from hell: Had some asshole connection leave a transaction open over the weekend. Came in Monday and the WAL file was like... I think 12 or 15GB, something insane. Database was still working but our backup script shit the bed because it ran out of disk space. Took forever to figure out what happened.
Silent checkpoint failures: PRAGMA wal_checkpoint(TRUNCATE)
is supposed to checkpoint and delete the WAL file. But if any connection has an open transaction, it silently fails. No error, WAL file stays huge, and you waste your Saturday figuring out why the disk is full.
Indexes: Actually the Most Important Thing
SQLite can only use one index per table per query, unlike PostgreSQL which can combine multiple indexes. Get this wrong and all your cache tuning is worthless.
-- This won't help multi-column queries
CREATE INDEX idx_users_name ON users(name);
CREATE INDEX idx_users_status ON users(status);
-- SQLite picks ONE index, scans for the rest
SELECT * FROM users WHERE name = 'Alice' AND status = 'active';
-- This actually works
CREATE INDEX idx_users_name_status ON users(name, status);
Column order matters: Put the most selective column first. If name
is unique and status
has 3 values, use (name, status)
not (status, name)
. I've seen developers create indexes in the wrong order and spend days wondering why queries are still slow.
The selectivity problem: Your perfect (name, status)
index is useless for WHERE status = 'active'
queries. SQLite can't use the right part of a composite index without the left part. You need separate indexes or you're fucked.
Use EXPLAIN QUERY PLAN or Guess Randomly
Before adding random indexes, figure out what SQLite is actually doing:
EXPLAIN QUERY PLAN
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id;
Bad signs:
- SCAN TABLE: No index, checking every single row
- USING TEMP B-TREE: Building temporary indexes in memory
- SEARCH TABLE: Using an index, but maybe the wrong one
Temp B-tree hell: If you see "USING TEMP B-TREE" for ORDER BY
or GROUP BY
, you need a composite index that includes those columns. SQLite is building temporary indexes in memory, which eats RAM and takes forever.
Why Connection Pools Actually Hurt SQLite Performance
Connection pooling with SQLite is weird. Each connection opens the database file and gets its own cache. More connections often hurts performance.
## This usually makes things worse
connection_pool = SQLiteConnectionPool(max_connections=50)
## This is better
connection_pool = SQLiteConnectionPool(max_connections=5)
Why fewer connections help: Each SQLite connection has its own page cache. 50 connections with 10MB cache each means 500MB of duplicated cached pages. Better to have 5 connections with 100MB cache each.
Thread safety lies: SQLite claims to be thread-safe but I've seen shared connections randomly corrupt data in production. Error was something like "database disk image is malformed" which made us think the SSD was dying. Took forever to figure out it was just multiple threads using the same connection without proper locking. One connection per thread or prepare for weird corruption bugs.
When to Give Up on SQLite
Sometimes the best SQLite optimization is switching to PostgreSQL. Consider switching if you need:
- Multiple writers hitting the database constantly
- Complex analytics with tons of joins
- Full-text search that doesn't suck
- Advanced JSON operations
I've seen teams waste months optimizing SQLite when a weekend PostgreSQL migration would have fixed everything. SQLite is great for read-heavy apps and embedded stuff, but don't push it past its limits.
That said, SQLite with proper tuning can handle way more load than most people think. Expensify processes millions of requests per day on SQLite.