Troubleshooting
PostgreSQL & databases

Fix "database is locked" with SQLite in Django

Fix SQLite database is locked errors in Django: concurrent writes, long transactions, WAL mode, timeouts, IMMEDIATE transactions, and when to migrate.

Problem statement

A Django site using SQLite in production intermittently fails with:

django.db.utils.OperationalError: database is locked

It usually appears under modest traffic, during admin edits while background jobs run, or when a management command runs alongside the web server. Locally, with one browser tab and runserver, it never happens.

SQLite allows many concurrent readers but only one writer at a time for the whole database file. When a connection wants to write and another connection holds the write lock, it waits up to a timeout (5 seconds by default in Python's sqlite3 module, which Django uses) and then raises "database is locked". Multiple Gunicorn workers, threads, Celery workers, and cron jobs all compete for that single lock.

SQLite can serve real production workloads with low write concurrency, and modern Django versions include options that make it much better at it. But the error is also a common sign that an application has outgrown SQLite.

SQLite write serializationRequestsseveral writersSQLiteone writer at a timeBusy timeoutwaits for the lockErrordatabase is lockedWAL mode and short transactions reduce contention; heavy writes need a server database.

Quick answer

  1. Enable WAL journal mode so readers do not block the writer and vice versa.
  2. Increase the lock wait timeout with OPTIONS: {"timeout": 20}.
  3. On Django 5.1+, set "transaction_mode": "IMMEDIATE" so write transactions take the lock at the start instead of failing mid-transaction.
  4. Keep transactions short: no network calls or slow work inside transaction.atomic(), and avoid ATOMIC_REQUESTS with slow views.
  5. Make sure the database file and its directory are on local disk (not NFS or a shared network volume) and writable by the app user.
  6. Reduce concurrent writers: fewer processes writing, batch background writes.
  7. If writes are frequent or concurrent by nature, migrate to PostgreSQL.

Step-by-step solution

1. Confirm the context

Find when and where the error happens:

sudo journalctl -u gunicorn --since "today" | grep -B 20 "database is locked" | grep -E "File \"/srv|database is locked" | head -20

Note which code paths appear: a view, an admin action, a Celery task, a management command. Also check what processes open the file:

sudo lsof /srv/myapp/data/db.sqlite3

2. Enable WAL mode

In the default rollback journal mode, a writer blocks readers during commits and readers can block writers from committing. Write-Ahead Logging (WAL) lets readers continue while a single writer appends to a separate log file, which removes most read/write contention.

WAL lets readers and a writer overlapRollback journal (default)✕a writer blocks readers✕readers block the writer✕locked errors under light loadWAL mode✓readers never block the writerone writer at a time still✓set once, stored in the fileWAL removes most locked errors for small sitesdjango-deployment.com
WAL needs the -wal and -shm files on the same local disk.

Set it once per database file (it is persistent):

sqlite3 /srv/myapp/data/db.sqlite3 "PRAGMA journal_mode=WAL;"

Or apply it from Django on each new connection, together with other pragmas. On Django 5.1 and later, use the init_command option:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "data" / "db.sqlite3",
        "OPTIONS": {
            "timeout": 20,
            "transaction_mode": "IMMEDIATE",
            "init_command": (
                "PRAGMA journal_mode=WAL;"
                "PRAGMA synchronous=NORMAL;"
                "PRAGMA busy_timeout=20000;"
            ),
        },
    }
}

On older Django versions without init_command for SQLite, set the pragmas in a connection_created signal handler.

WAL mode creates db.sqlite3-wal and db.sqlite3-shm files next to the database. The directory must be writable by the app user, and backups must account for them (step 7).

synchronous=NORMAL is a common pairing with WAL: it reduces fsync calls and remains safe against application crashes; a power loss can lose the most recent transactions but does not corrupt the database.

3. Increase the timeout

timeout (seconds) controls how long a connection waits for a lock before raising. The default of 5 seconds is short when several processes write. Values of 15 to 30 seconds smooth over brief contention, at the cost of slower requests during busy moments. It does not fix long transactions; it only waits longer for them.

4. Use IMMEDIATE transactions

By default, SQLite starts transactions in DEFERRED mode: the transaction reads first and only asks for the write lock when it first writes. If another connection already holds it, SQLite may fail immediately with a busy error instead of waiting, because waiting could deadlock with a reader that also wants to write.

the SQLite settings that matter"timeout":20,"transaction_mode": "IMMEDIATE","init_command": "PRAGMA journal_mode=WAL; PRAGMAsynchronous=NORMAL;"1231Wait up to 20 s for the lock instead of 5.2Take the write lock at BEGIN: no upgrade deadlocks.3Safe with WAL, and much faster.transaction_mode and init_command need Django 5.1+django-deployment.com
Deferred transactions that later write are the classic cause of instant locked errors.

Django 5.1 added the transaction_mode option. With IMMEDIATE, every transaction.atomic() block acquires the write lock at BEGIN, where waiting with the timeout works reliably. This change alone resolves many "database is locked" errors in apps that read and then write within one transaction.

5. Keep transactions short

Anything slow inside an atomic block holds the write lock for its duration:

# Holds the lock while calling an external API
with transaction.atomic():
    order = Order.objects.create(customer=customer, total=total)
    payment_gateway.charge(order)   # seconds of network time
    order.mark_paid()

Restructure so the lock is held only for database work:

result = payment_gateway.charge_intent(customer, total)

with transaction.atomic():
    order = Order.objects.create(customer=customer, total=total, payment_ref=result.id)
    order.mark_paid()

Also check:

  • ATOMIC_REQUESTS = True wraps each whole view in a transaction; slow views then hold locks for the entire request
  • management commands that update many rows in one transaction; commit in batches
  • Django admin bulk actions that touch thousands of rows

6. Storage and file location

SQLite relies on file locks that network filesystems often implement poorly:

  • do not put the database on NFS, SMB, or network-backed shared volumes
  • in Docker, use a named volume on local disk
  • on PaaS platforms with ephemeral or shared storage, SQLite is usually not appropriate at all
SQLite needs a local diskSafe?Local disk / block volume✓yesDocker named volume✓yes, one hostNFS, EFS, SMB✕no: locking unreliableContainer layer✕no: lost on redeployNetwork filesystems break SQLite locking in subtle waysdjango-deployment.com
Keep the database file in a directory the app user can write to.

The directory containing the database must be writable, because SQLite creates journal or WAL files there:

ls -ld /srv/myapp/data
sudo -u myapp touch /srv/myapp/data/.write-test && sudo rm /srv/myapp/data/.write-test

A read-only directory produces attempt to write a readonly database, a different error with a similar root.

7. Backups with WAL

Copying db.sqlite3 while the app runs can produce an inconsistent backup, and in WAL mode recent transactions may still live in the -wal file. Use SQLite's online backup:

sqlite3 /srv/myapp/data/db.sqlite3 ".backup '/var/backups/myapp-$(date +%F).sqlite3'"

The backup command copies a consistent snapshot while other connections continue.

8. Reduce concurrent writers

  • Gunicorn: fewer workers with threads keeps concurrency for reads while limiting separate processes competing for writes. SQLite serializes writes regardless, so extra processes add contention without adding write throughput.
  • Background jobs: schedule write-heavy jobs away from peak traffic, and batch updates with bulk_update or bulk_create.
  • Sessions: the database session backend writes on many requests. Signed cookie sessions (django.contrib.sessions.backends.signed_cookies) or a cache backend remove those writes, with their own trade-offs.
  • Analytics and counters: avoid writing a row on every page view; aggregate in memory or a cache and flush periodically.

9. Know when to move to PostgreSQL

SQLite is a good fit for low-write sites with a single server. Consider PostgreSQL when:

  • you run more than one application server
  • write traffic is steady rather than occasional (orders, messages, collaborative editing)
  • background workers and web requests write concurrently
  • you need row-level locking, select_for_update() semantics under concurrency, or online schema changes on large tables
stay on SQLite or move?How many processes write, and how often?one server, light writesStay on SQLiteWAL, IMMEDIATE, backupsworkers + tasks write a lotMove to PostgreSQLreal concurrencymore than one serverPostgreSQL nowSQLite cannot be sharedLocked errors that survive WAL and IMMEDIATE are the signal to movedjango-deployment.com
dumpdata and loaddata, or pgloader, handle the migration.

Migrating is straightforward for small databases with dumpdata and loaddata, or with dedicated tools for larger ones. Test the migration on a copy first.

10. Verify

sqlite3 /srv/myapp/data/db.sqlite3 "PRAGMA journal_mode;"
python manage.py shell -c "
from django.db import connection
with connection.cursor() as c:
    c.execute('PRAGMA journal_mode'); print(c.fetchone())
    c.execute('PRAGMA busy_timeout'); print(c.fetchone())
"

Then run a small concurrent write test in staging, for example several processes creating rows in a loop, while browsing the site.

Verification check:

  • journal_mode reports wal
  • busy_timeout matches your setting
  • no "database is locked" errors during normal traffic and background jobs
  • backups use .backup and restore successfully

Explanation

SQLite's single-writer design is simple and fast, but it turns every concurrent write into a queue. The error appears when a connection waits longer than its timeout, or when a deferred transaction discovers it cannot upgrade to a write lock. WAL mode removes reader and writer blocking, IMMEDIATE transactions make waiting predictable, and short transactions keep the queue moving. When the workload itself needs parallel writes, a client-server database is the right tool.

Edge cases / notes

Error during migrations

Running migrate while the web app writes can fail with lock errors, and some SQLite schema changes rebuild whole tables. Run migrations during low traffic or with the app stopped for large changes.

Tests and CI

Parallel test runs (--parallel) create separate test database files for each process, so lock errors in tests usually point at threads or background tasks inside a single test process.

Litestream and replication tools

Tools that stream SQLite changes to object storage improve durability for single-server deployments, but they do not add write concurrency.

Common mistakes

  • Putting the SQLite file on a network filesystem or shared volume.
  • Copying the database file for backups while the app runs.
  • Increasing the timeout without fixing long transactions.
  • Adding more Gunicorn workers to "handle load" on SQLite.

Next steps

FAQ

Can Django use SQLite in production?

Yes, for low-write, single-server sites, especially with WAL mode, a sensible timeout, and short transactions. Sites with concurrent writes or multiple servers should use PostgreSQL.

What does "database is locked" mean in Django?

A connection needed SQLite's write lock and could not get it before its timeout, because another connection was writing or holding a long transaction.

How do I enable WAL mode for Django's SQLite database?

Run PRAGMA journal_mode=WAL; once on the database file, or set it through init_command in the database OPTIONS on Django 5.1 and later.

What does transaction_mode IMMEDIATE do in Django?

Available since Django 5.1 for SQLite, it makes transactions acquire the write lock when they begin. Waiting then happens at the start with the configured timeout, instead of failing when a read transaction later tries to write.

Keep going