Run Django on SQLite in Production
Run Django on SQLite in production safely: WAL mode, busy timeout and IMMEDIATE transactions, file placement and permissions, online backups, Litestream replication, and when to move on.
Problem statement
The standard advice is "never use SQLite in production". For many Django apps (internal tools, small SaaS products, content sites, single-server deployments with modest write volume), SQLite is fast, has zero operational overhead, and removes a network hop from every query. What fails is SQLite with default settings: concurrent writes produce database is locked, a copy of the file taken while the app runs can be corrupt, and the database is lost with the server if nothing replicates it.
This guide configures Django's SQLite backend for production, sets up safe backups and continuous replication, and lists the signals that mean it is time to move to PostgreSQL.
- Use Django 5.1 or newer and set
OPTIONSwithtransaction_mode: "IMMEDIATE", atimeout, and aninit_commandthat enables WAL mode and sane pragmas. - Store the database file outside the code directory, on a local disk (never a network filesystem), writable by the app user, with its directory writable too.
- Keep write transactions short; move slow work out of
transaction.atomicblocks. - Back up with the SQLite online backup API or
VACUUM INTO, never by copying the file while the app runs. - Stream changes to object storage with Litestream for near-real-time recovery.
- Move to PostgreSQL when you need multiple app servers, heavy concurrent writes, or database-level features SQLite lacks.
Step-by-step solution
1. Configure the database
# settings/production.py
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/srv/myapp/data/db.sqlite3",
"OPTIONS": {
"transaction_mode": "IMMEDIATE",
"timeout": 5,
"init_command": (
"PRAGMA journal_mode=WAL;"
"PRAGMA synchronous=NORMAL;"
"PRAGMA foreign_keys=ON;"
"PRAGMA mmap_size=134217728;"
"PRAGMA cache_size=-20000;"
),
},
}
}
What each setting does:
journal_mode=WAL: writers append to a write-ahead log while readers keep reading the main file. Readers no longer block writers or the other way round. WAL mode is stored in the database file and persists.transaction_mode: "IMMEDIATE": Django starts transactions withBEGIN IMMEDIATE, which takes the write lock at the start. With the default deferred mode, a transaction that reads and then writes can fail withdatabase is lockedimmediately instead of waiting, because SQLite cannot upgrade the lock safely.timeout: seconds to wait for the write lock before raisingdatabase is locked.synchronous=NORMAL: safe with WAL (committed transactions survive application crashes; a power loss can lose the last transactions but not corrupt the database) and much faster thanFULL.mmap_sizeandcache_sizetrade memory for fewer reads; adjust for your server.
transaction_mode and init_command were added in Django 5.1. On older versions, set the pragmas in a connection_created signal handler.
2. Place the file correctly
sudo mkdir -p /srv/myapp/data
sudo chown myapp:myapp /srv/myapp/data
sudo chmod 750 /srv/myapp/data
SQLite creates db.sqlite3-wal and db.sqlite3-shm next to the database, so the directory must be writable by the app user, not just the file. Keep the database:
- outside the release directory, so deploys do not replace it;
- on a local disk or block volume, never on NFS, SMB, or other network filesystems, where file locking is unreliable;
- in a named volume or bind mount if the app runs in a container.
3. Keep write transactions short
Only one write transaction runs at a time. A view that holds a transaction open while calling an external API blocks every other writer for that duration:
# Bad: the write lock is held during a slow HTTP call
with transaction.atomic():
order = Order.objects.create(...)
payment_api.charge(order)
order.mark_paid()
# Better: short transactions around the database work
with transaction.atomic():
order = Order.objects.create(...)
result = payment_api.charge(order)
with transaction.atomic():
order.mark_paid(result)
Avoid ATOMIC_REQUESTS = True with SQLite: it wraps each request, including slow parts, in one transaction.
Background workers (Celery, cron jobs) are additional writers. Batch their writes into small transactions.
4. Back up safely
Copying db.sqlite3 while the app is running can capture a half-written state, and ignores committed data still in the WAL file. Use one of the safe methods:
# Online backup API through the sqlite3 CLI
sqlite3 /srv/myapp/data/db.sqlite3 ".backup '/srv/myapp/backups/db-$(date +%F-%H%M).sqlite3'"
# Or a compacted copy
sqlite3 /srv/myapp/data/db.sqlite3 "VACUUM INTO '/srv/myapp/backups/db-$(date +%F-%H%M).sqlite3'"
Verify a backup by opening it and running PRAGMA integrity_check;. Ship backups off the server.
5. Replicate continuously with Litestream
Litestream runs next to the app, reads the WAL, and streams changes to S3-compatible storage, so you can restore to within seconds of a failure.
# /etc/litestream.yml
dbs:
- path: /srv/myapp/data/db.sqlite3
replicas:
- url: s3://myapp-backups/db
sudo systemctl enable --now litestream
sudo journalctl -u litestream -f
Credentials come from the environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) or the config file. The configuration format has changed between Litestream releases, so check the documentation for the version you install.
Restore test (do this before you need it):
litestream restore -o /tmp/restore-test.sqlite3 s3://myapp-backups/db
sqlite3 /tmp/restore-test.sqlite3 "PRAGMA integrity_check;"
6. Deploy with migrations
Migrations take the write lock and may rebuild tables (SQLite implements many ALTER TABLE operations by copying the table). On large tables, that blocks writes for the duration. Take a backup before migrating, and run migrations at a quiet time for large changes.
Verification check:
sqlite3 /srv/myapp/data/db.sqlite3 "PRAGMA journal_mode;"
ls -l /srv/myapp/data/
python manage.py dbshell <<< "PRAGMA foreign_keys;"
Expect wal, the -wal and -shm files owned by the app user, and 1 for foreign keys.
Explanation
SQLite is an in-process library: queries run inside your Gunicorn workers with no network round-trip, which makes reads extremely fast. Its concurrency model is many readers and one writer at a time. WAL mode makes readers and the writer independent, BEGIN IMMEDIATE makes writers queue predictably instead of failing, and the timeout sets how long they queue. Because the database is a file on one machine, durability depends entirely on your backup and replication setup, which is what Litestream provides.
Edge cases / notes
When to move to PostgreSQL
Switch when you need more than one application server, when write contention shows up as lock timeouts under normal load, when migrations on large tables block for too long, or when you need features such as row-level locking with select_for_update(skip_locked=True), full-text search at scale, or managed high availability. Moving is easier early: use dumpdata/loaddata for small databases or a dedicated migration tool for larger ones.
Containers and platforms
Platforms with ephemeral filesystems or multiple instances (most PaaS offerings, autoscaled containers) are a poor fit unless the platform provides a persistent volume attached to exactly one instance.
When to automate this
Automate the backup schedule and, more importantly, a regular restore test that downloads the latest replica, runs integrity_check, and reports the result. A backup that has never been restored is an assumption.
Next steps
- If you see lock errors, work through Fix "database is locked" with SQLite in Django.
- Plan the upgrade path with How to Connect Django to PostgreSQL in Production.
- Review your recovery plan with the Django Backup and Recovery Checklist.
- Serve the app with Deploy Django with Gunicorn and Nginx on Ubuntu.
FAQ
Is SQLite good enough for a production Django site?
For single-server apps with moderate write volume, yes, when configured with WAL mode, immediate transactions, a busy timeout, and real backups. It is not suitable for multi-server deployments.
Why does Django raise "database is locked" with SQLite?
Another connection holds the write lock longer than the timeout, or a deferred transaction tried to upgrade to a write lock. Use transaction_mode: "IMMEDIATE", a timeout, and short write transactions.
Can I back up SQLite by copying the file?
Not while the application is running. Use the .backup command, VACUUM INTO, or Litestream.
Can several Gunicorn workers share one SQLite database?
Yes. Multiple processes on the same machine can use one database file safely; writes are serialised. Multiple machines cannot.