modified
content/posts/optimizing-sqlite-for-django-in-production.md
@@ -10,6 +10,12 @@ cover_image: sqlite-django-logs.webpSQLite runs most of my smaller Django projects in production. It's fast, it's one file to back up, and it takes an entire service out of my stack. The problem is the default Django config is tuned for development, not production. The first time a background worker writes while a request reads you'll start seeing `database is locked` in your logs. A few PRAGMAs and one Django option fix most of it.> **Update — 2026-04-26.** A week after publishing this, my SQLite-backed status monitor corrupted with `database disk image is malformed` and ran broken for several days before I noticed. The recipe below is still what I run, with one line removed: `PRAGMA mmap_size=134217728`.>> Here's what bit me. SQLite has a WAL-reset race (introduced in 3.7.0, fixed in **3.51.3** released 2026-03-13) that triggers when two or more connections on the same file write or checkpoint simultaneously — exactly what you have with multi-worker Gunicorn, or a worker plus a background scheduler. The race itself is rare and usually self-corrects on the next checkpoint. mmap is what turns a transient race into a structurally broken file. [SQLite's mmap docs](https://www.sqlite.org/mmap.html) warn that it "is more sensitive to bugs in the application code or undefined behavior" and "if a corruption happens, mmap can spread it more widely." The integrity check on my dead database came back full of *child page depth differs* and *2nd reference to page X* errors — textbook mmap-spread signatures, not vanilla WAL-race ones.>> So: if you have multiple processes writing to the same SQLite file and your base image still has SQLite < 3.51.3 (Alpine 3.21 ships 3.48, 3.22 ships 3.49 as of this writing), drop the `PRAGMA mmap_size` line until you can upgrade. The rest of the config stands. Single-worker Gunicorn with no background processes is unaffected. Recovery, for the curious, was `sqlite3 .recover` into a fresh file — kept all but five rows out of eight thousand.Here's the full `DATABASES` block I use. Requires Django 5.1 or newer.```python