Fix: SQLite Database Is Locked Error
Part of: Database Errors
Quick Answer
How to fix the SQLite 'database is locked' error caused by concurrent writes, long-running transactions, missing WAL mode, busy timeout, and unclosed connections.
The Lock That Will Not Release
The first time I shipped a system on SQLite at meaningful concurrency, I learned about this error at 4am the day after launch. Two background workers, both writing to the same file, both blocking, and a queue of stalled requests behind them. The fix took two lines (WAL mode, busy timeout), but I have respected the SQLite locking model ever since. You run a query against your SQLite database, and you get:
sqlite3.OperationalError: database is lockedIn Node.js, it looks like:
Error: SQLITE_BUSY: database is lockedIn Go:
database/sql: database is locked (5)Or from the SQLite CLI:
Error: database is lockedThe operation hangs for a while, then fails. Sometimes it works when you retry, sometimes it doesn’t. The error appears random, but it has a clear cause.
Quick Reference Before You Dive In
If you arrived here from Google with SQLITE_BUSY, the five facts that resolve roughly 90 percent of cases:
- The first thing to check is
PRAGMA journal_mode;. If it returnsdelete, every write blocks every read and vice versa. Switch toWALfirst; this alone fixes the majority of “random database is locked” reports. The SQLite WAL documentation is the canonical reference. WAL mode is persistent: set it once per database. - SQLite allows many readers but only ONE writer. This is the core constraint. The SQLite file locking model explains the lock state machine. Multi-process write workloads simply cannot scale on SQLite the way they do on PostgreSQL; if your write QPS is high, switch databases.
- The default busy timeout is ZERO. SQLite returns
SQLITE_BUSYinstantly on lock contention. SetPRAGMA busy_timeout = 5000;(or passtimeout=5tosqlite3.connect) and the back-off retry handles most transient contention. - Long-running transactions are the biggest hidden cause. A
BEGINfollowed by a network call, an external API, or any slow operation holds the write lock for the entire wait. Move slow I/O outside the transaction; write the results in a single fast batch. - SQLite on network filesystems (NFS, SMB, OneDrive, Dropbox) is broken by design. File locking is silently unreliable. If your database file lives on a network mount or sync folder, that is your problem; move it to a local disk or switch to PostgreSQL.
The rest of this article walks through each of those in detail, plus the failure modes most other guides skip.
How SQLite Locking Actually Works
SQLite uses file-level locking. Unlike PostgreSQL or MySQL, there is no server process managing concurrent access. The database is a single file on disk, and SQLite uses OS-level file locks to coordinate reads and writes.
Here is what triggers the lock:
Concurrent writes: SQLite allows multiple simultaneous readers, but only one writer at a time. When a second connection tries to write while the first is still writing, it gets locked out.
Long-running transactions: A transaction that stays open for too long holds a lock the entire time. Every other write operation waits, and eventually times out.
Unclosed connections: A connection that was never properly closed can hold a lock indefinitely. This is common in scripts that crash mid-execution or in web frameworks with poor connection lifecycle management.
Default journal mode (DELETE): The default journal mode forces readers to wait for writers and vice versa. This creates unnecessary contention.
No busy timeout: By default, SQLite returns
SQLITE_BUSYimmediately when it encounters a lock. It doesn’t wait at all. A zero-second timeout means even brief lock overlaps cause failures.NFS or network filesystems: SQLite’s file locking doesn’t work reliably on network-mounted drives. If your database file sits on NFS, SMB, or a Docker volume backed by a network share, locking breaks silently.
Understanding these causes points directly to the fixes below.
Diagnostic Timeline
database is locked is the SQLite error that wastes the most engineer-hours, because the obvious fix (bumping busy_timeout) almost never solves the real problem. Here is how to triage it efficiently.
Minute 0: The reflex you should resist. Your first instinct is to set PRAGMA busy_timeout = 30000 and hope the lock clears. It usually does not, because the lock is held by a long-running transaction or a stuck WAL checkpoint, not by raw lock contention. A 30-second timeout just delays the failure by 30 seconds.
Minute 1: Confirm WAL is actually on. Run PRAGMA journal_mode; from the same connection that is failing. If it returns delete, every write blocks every read and vice versa. That is your problem 80% of the time. Switch to WAL first, then re-test before tuning anything else. WAL mode is the single biggest improvement you can make to a SQLite concurrency story.
Minute 3: Look at the filesystem, not the database. ls -la mydb.sqlite3* tells you more than any pragma. The presence of a -journal file when WAL is enabled means a write transaction was abandoned mid-flight and SQLite is mid-recovery. A -wal file larger than 4 MB means the WAL has not been checkpointed recently, usually because a long-running read transaction is preventing it. A -shm file owned by a different user than your application is a classic “two processes fighting over the same database” symptom.
Minute 5: Classify the concurrency pattern. There are only three patterns that produce this error in normal operation:
- Many readers, one writer: WAL mode handles this perfectly. If you are still locked, you are not actually in WAL mode (see Minute 1).
- Single connection shared across threads: Python’s
sqlite3module setscheck_same_thread=Trueby default. Crossing the connection between threads either crashes or holds a lock the rest of the application cannot acquire. Use one connection per thread, or a single writer connection serialized by a Python lock. - Process killed mid-transaction: leaves a stale
-journalor-walfile. SQLite recovers on the next open, but only if no other process has the database open at that moment. If you see persistent lock errors after a crash, no application is reaching the recovery branch.
Minute 7: The wrong path: deleting the WAL file. Search results often suggest rm mydb.sqlite3-wal mydb.sqlite3-shm to “reset the lock.” This corrupts the database if any committed-but-not-yet-checkpointed transactions live in the WAL. Never delete those files while any process has the database open. The correct recovery is to open the database in a fresh process and let SQLite checkpoint it: sqlite3 mydb.sqlite3 "PRAGMA wal_checkpoint(TRUNCATE);".
Minute 10: Find the actual blocker. Enable trace logging on every connection (conn.set_trace_callback(print) in Python) and look for the SQL statement that ran immediately before the lock. Nine times out of ten it is one of: a BEGIN followed by network I/O before COMMIT, an ORM transaction wrapped around an entire HTTP request, or a long-running analytical SELECT against a connection that is also expected to write.
Real root-cause distribution: roughly 45% missing WAL mode, 20% transaction held open during slow I/O (HTTP calls, file reads, user prompts inside a BEGIN), 15% network filesystem (NFS, SMB, Dropbox, OneDrive), 10% multi-threaded access without per-thread connections, 10% external process (DB Browser, IDE plugin) holding the file open. Walk that list before raising busy_timeout.
When to Use Which Fix
The next eight sections cover the fixes in detail. The table below maps your situation to the recommended fix.
| Your situation | Recommended fix | Why |
|---|---|---|
Default journal mode (DELETE), random lock errors | Fix 1: enable WAL mode | Readers and writers stop blocking each other |
SQLITE_BUSY returned instantly with no wait | Fix 2: set busy_timeout to 5000+ ms | Default is 0, retry back-off handles transient contention |
| Persistent lock errors after a script crash | Fix 3: ensure connections close in try/finally or with closing() | Unclosed connections hold locks indefinitely |
BEGIN followed by HTTP / file I/O before COMMIT | Fix 4: move slow work outside the transaction | Long-running transactions are the #1 hidden cause |
| Multi-threaded app contending on writes | Fix 5: single writer connection + lock, per-thread readers | SQLite’s one-writer model needs explicit serialization |
| Read-heavy workload mixed with writes | Fix 6: open read-only connections with ?mode=ro | Read-only never acquires write lock |
| Database file open in DB Browser / IDE plugin | Fix 7: lsof / fuser to find the holder, then close it | External process holding the file |
| Database file on NFS, SMB, Dropbox, OneDrive | Fix 8: move to local disk or switch to PostgreSQL | File locking unreliable on network filesystems |
If multiple rows apply, pick the topmost one.
Fix 1: Enable WAL Mode
This is the single most impactful change you can make. WAL (Write-Ahead Logging) mode replaces SQLite’s default rollback journal with a write-ahead log. The result: readers no longer block writers, and writers no longer block readers.
In the default DELETE journal mode, a write transaction locks the entire database file. In WAL mode, writes go to a separate log file while readers continue reading from the main database.
Enable it by running:
PRAGMA journal_mode=WAL;In Python:
import sqlite3
conn = sqlite3.connect("mydb.sqlite3")
conn.execute("PRAGMA journal_mode=WAL;")In Node.js with better-sqlite3:
const Database = require('better-sqlite3');
const db = new Database('mydb.sqlite3');
db.pragma('journal_mode = WAL');In Django, add it to your DATABASES config:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'OPTIONS': {
'init_command': 'PRAGMA journal_mode=WAL;',
},
}
}Note: WAL mode is persistent. You set it once and it stays until you explicitly change it back. You can verify the current mode with PRAGMA journal_mode;.
WAL mode does have trade-offs. Read performance is slightly lower for very large transactions because readers must check the WAL file. And the WAL file can grow large if a long-running read transaction prevents checkpointing. But for the vast majority of use cases, WAL mode is strictly better.
I have looked into WAL2 (the experimental branch with even better write concurrency) for high-throughput cases and pulled back every time. WAL2 is not in mainline SQLite, which means your pip install pysqlite3 or apt install sqlite3 does not include it. Unless you are building SQLite from source and are willing to track the begin-concurrent branch, classical WAL is the right answer.
Fix 2: Set a Busy Timeout
By default, SQLite returns SQLITE_BUSY the instant it encounters a lock. It doesn’t retry, it doesn’t wait; it just fails. Setting a busy timeout tells SQLite to wait a specified number of milliseconds before giving up.
In Python:
conn = sqlite3.connect("mydb.sqlite3")
conn.execute("PRAGMA busy_timeout = 5000;") # Wait up to 5 secondsOr use the timeout parameter directly:
conn = sqlite3.connect("mydb.sqlite3", timeout=10) # 10 secondsIn Node.js with better-sqlite3:
const db = new Database('mydb.sqlite3', { timeout: 5000 });In Go with mattn/go-sqlite3:
db, err := sql.Open("sqlite3", "mydb.sqlite3?_busy_timeout=5000")In the SQLite CLI:
.timeout 5000A timeout of 5000ms (5 seconds) works well for most applications. For high-write workloads, increase it to 10–30 seconds. If you’re still hitting timeouts after 30 seconds, the underlying problem is something else, likely a long-running transaction or an unclosed connection.
Note: The busy timeout uses a back-off retry strategy. SQLite doesn’t spin-lock for the full duration. It sleeps for short intervals between retry attempts, gradually increasing the sleep time.
Fix 3: Close Connections Properly
An unclosed connection can hold a lock forever. This is one of the most common causes of persistent “database is locked” errors, especially in Python scripts and web applications.
Always use context managers or try/finally blocks:
Python, use a context manager:
import sqlite3
# Connection is guaranteed to close even if an exception occurs
with sqlite3.connect("mydb.sqlite3") as conn:
conn.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
conn.commit()Node.js, close on exit:
const db = new Database('mydb.sqlite3');
process.on('exit', () => db.close());
process.on('SIGINT', () => { db.close(); process.exit(); });
process.on('SIGTERM', () => { db.close(); process.exit(); });
// Or in an Express app, close on server shutdownPython, watch out for this mistake:
# BAD: Connection stays open if an exception occurs between connect and close
conn = sqlite3.connect("mydb.sqlite3")
conn.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
conn.commit()
conn.close() # Never reached if execute() throwsA footgun that took me embarrassingly long to learn: in Python, with sqlite3.connect(...) commits or rolls back the transaction when the block exits, but it does not close the connection. The connection stays open and keeps its lock. You need an explicit conn.close() after the with block, or wrap it in contextlib.closing():
from contextlib import closing with closing(sqlite3.connect("mydb.sqlite3")) as conn: with conn: # This handles commit/rollback conn.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
Check for orphaned connections by looking at the -wal and -shm files. If these files exist when no process should be accessing the database, something didn’t close properly.
Fix 4: Keep Transactions Short
Long-running transactions are a primary cause of lock contention. A write transaction that stays open for seconds, or minutes, blocks every other write for its entire duration.
Bad: transaction open during slow I/O:
conn = sqlite3.connect("mydb.sqlite3")
conn.execute("BEGIN")
for url in urls:
data = requests.get(url).json() # Network I/O inside a transaction
conn.execute("INSERT INTO results (data) VALUES (?)", (json.dumps(data),))
conn.commit()Good: fetch first, then write in a quick batch:
conn = sqlite3.connect("mydb.sqlite3")
# Do slow work outside the transaction
results = []
for url in urls:
results.append(requests.get(url).json())
# Write everything in one fast transaction
conn.execute("BEGIN")
for data in results:
conn.execute("INSERT INTO results (data) VALUES (?)", (json.dumps(data),))
conn.commit()The same principle applies to any slow operation: file I/O, API calls, user input, or complex computations. Do the slow work outside the transaction, then write the results in a single, fast batch.
For bulk inserts, use executemany(); it’s faster than individual execute() calls and keeps the transaction shorter:
conn.executemany(
"INSERT INTO results (data) VALUES (?)",
[(json.dumps(d),) for d in results]
)
conn.commit()Fix 5: Use Connection Pooling Correctly
Connection pooling can either help or hurt, depending on how you configure it. The key rule for SQLite: use a single writer connection and multiple reader connections.
In Python with SQLAlchemy:
from sqlalchemy import create_engine
# Use StaticPool for single-threaded apps
engine = create_engine(
"sqlite:///mydb.sqlite3",
connect_args={"check_same_thread": False},
pool_size=1, # Only one connection for writes
max_overflow=0,
)For multi-threaded applications, the pattern is a single write connection shared across threads (serialized with a mutex) and a pool of read-only connections:
import threading
import sqlite3
class SQLitePool:
def __init__(self, db_path):
self.db_path = db_path
self.write_lock = threading.Lock()
def write(self, query, params=()):
with self.write_lock:
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA busy_timeout=5000;")
try:
conn.execute(query, params)
conn.commit()
finally:
conn.close()
def read(self, query, params=()):
conn = sqlite3.connect(self.db_path, uri=True)
conn.execute("PRAGMA journal_mode=WAL;")
try:
return conn.execute(query, params).fetchall()
finally:
conn.close()Why this matters: SQLite handles multiple readers gracefully, but concurrent writers always contend for the same lock. By funneling all writes through a single connection (or serializing them with a lock), you eliminate write contention entirely. Reads can happen in parallel without issue, especially in WAL mode.
In frameworks like Django and Flask, SQLite pooling is often handled automatically. But if you’re managing connections manually, this pattern prevents the “database is locked” error in multi-threaded environments.
Fix 6: Use Read-Only Connections for Reads
If a connection only needs to read data, open it in read-only mode. This prevents accidental writes and avoids acquiring write locks unnecessarily.
In Python:
conn = sqlite3.connect("file:mydb.sqlite3?mode=ro", uri=True)
cursor = conn.execute("SELECT * FROM users")
rows = cursor.fetchall()
conn.close()In Node.js with better-sqlite3:
const db = new Database('mydb.sqlite3', { readonly: true });In Go:
db, err := sql.Open("sqlite3", "file:mydb.sqlite3?mode=ro")Read-only connections have two benefits:
- They never acquire a
RESERVEDorEXCLUSIVElock, so they can’t block writers. - They fail fast if code accidentally tries to write, which catches bugs early.
In WAL mode, read-only connections can operate completely independently of write operations. This is the ideal setup for web applications where most requests are reads.
Fix 7: Check for External Processes Holding Locks
Sometimes the lock isn’t caused by your code; another process has the database open. This is common when:
- A database browser (DB Browser for SQLite, DBeaver) has the file open
- A crashed process left a stale lock
- A cron job or background worker is running concurrent writes
- An IDE plugin is auto-inspecting the database file
Find processes using the database file:
Linux/macOS:
fuser mydb.sqlite3
lsof mydb.sqlite3Windows:
handle.exe mydb.sqlite3If you find a stale lock from a crashed process, check for leftover journal files:
ls -la mydb.sqlite3*You might see:
mydb.sqlite3
mydb.sqlite3-journal # DELETE mode journal
mydb.sqlite3-wal # WAL mode log
mydb.sqlite3-shm # WAL mode shared memoryIf no process is using the database and a -journal file exists, SQLite will automatically recover it on the next connection. If recovery fails, you can try:
sqlite3 mydb.sqlite3 "PRAGMA integrity_check;"Warning: Never manually delete -wal or -shm files while any process has the database open. This corrupts the database. Only remove them if you’re certain no process is connected and integrity_check passes.
Fix 8: Avoid SQLite on Network Filesystems
SQLite depends on OS-level file locking for correctness. Network filesystems (NFS, SMB/CIFS, SSHFS) often implement file locking incorrectly or not at all. The result: silent data corruption, phantom “database is locked” errors, or both.
Common scenarios where this bites you:
- Docker volumes backed by network mounts
- NFS home directories on shared servers
- WSL2 accessing files on the Windows filesystem (
/mnt/c/...): this works but has performance and locking quirks - Cloud-synced folders (Dropbox, Google Drive, OneDrive): sync engines modify the file externally and break locks
If you must use SQLite on a network path, consider these workarounds:
- Copy the database locally, work with it, then copy it back:
cp /nfs/shared/mydb.sqlite3 /tmp/mydb.sqlite3
# Do your work on /tmp/mydb.sqlite3
cp /tmp/mydb.sqlite3 /nfs/shared/mydb.sqlite3- Use SQLite’s
PRAGMA locking_mode=EXCLUSIVEto hold the lock for the entire session, reducing the window for NFS locking bugs:
PRAGMA locking_mode=EXCLUSIVE;- Switch to PostgreSQL or MySQL if multiple processes need concurrent access on a network filesystem. This is the correct solution for shared, multi-process production workloads. SQLite is designed for local, single-application use.
If you’re running into similar locking issues in MySQL, the error message and solutions are different; see MySQL Lock Wait Timeout Exceeded for details on that scenario.
Causes I Have Hit That Are Easy to Miss
If none of the above fixes solved your problem, work through these less common causes I have personally tracked down:
Check your SQLite version. WAL mode and busy timeout behavior improved significantly in SQLite 3.7.0+ and again in 3.34.0+. Check your version:
SELECT sqlite_version();import sqlite3
print(sqlite3.sqlite_version)If you’re on an older version, upgrade. Many Linux distributions ship outdated SQLite libraries.
Check for disk space. SQLite needs space to create journal and WAL files. If the disk is full, transactions fail with a lock error instead of a disk-full error:
df -h .Check file permissions. SQLite needs write access to the database file and the directory containing it (for creating journal files). This trips people up when they’ve set the file writable but the directory is read-only:
ls -la mydb.sqlite3
ls -la $(dirname mydb.sqlite3)If you’re seeing file permission errors in Python, that’s a different category of problem; check Python PermissionError: errno 13 for those fixes.
Check for antivirus interference. Some antivirus software on Windows locks database files during scanning, causing intermittent “database is locked” errors. Add your database directory to the antivirus exclusion list.
Consider switching databases. SQLite is excellent for embedded applications, local development, and read-heavy workloads with moderate traffic. But it’s not designed for high-concurrency write workloads. If you’re running a production web app with dozens of concurrent write operations per second, PostgreSQL is the right tool. A connection refused error in PostgreSQL is easier to debug than fighting SQLite’s concurrency model at scale.
Debug with PRAGMA busy_timeout tracing. If you need to find exactly which query is holding the lock, enable SQLite tracing:
conn.set_trace_callback(print)This prints every SQL statement as it executes, helping you identify the long-running transaction that’s causing contention.
Check your ORM’s transaction management. Django’s default ATOMIC_REQUESTS wraps every HTTP request in a transaction. If a view takes 10 seconds (due to external API calls, slow template rendering, etc.), that transaction holds a lock for the entire request. Disable ATOMIC_REQUESTS and manage transactions manually around the specific queries that need them. The same principle applies if you’re hitting Django OperationalError: no such table; ORM configuration issues compound locking problems.
For Python applications throwing KeyError exceptions during database result processing, that is a separate code-level issue and not a locking issue at all; confirm by isolating the cursor fetch from the row-handling code.
If your Node.js application cannot find the SQLite module itself (a missing native binding after install), that is an install-time problem rather than a runtime lock. Reinstall with npm rebuild better-sqlite3 and verify the Node version matches the prebuilt binary.
Force a WAL checkpoint and watch what happens. If your -wal file is large and writes feel blocked, the checkpointer may be unable to catch up because a long-lived read connection holds the WAL open. Run an explicit checkpoint:
PRAGMA wal_checkpoint(TRUNCATE);The result is three integers: (busy, log_frames, checkpointed_frames). If busy is 1, a reader is preventing the checkpoint. Find that reader. The most common culprit is a connection in your ORM’s connection pool that was never returned. Restart the application to evict it, then investigate why the pool leaked.
Check for BEGIN IMMEDIATE vs BEGIN DEFERRED. SQLite’s default is BEGIN DEFERRED, which acquires the write lock only when the first write happens, not at BEGIN. If two connections both run BEGIN, then both try INSERT, the second one fails with SQLITE_BUSY even though it appeared to be inside a transaction the whole time. Use BEGIN IMMEDIATE (or BEGIN EXCLUSIVE) when you know the transaction will write. This converts an unpredictable mid-transaction lock failure into a predictable BEGIN-time wait that respects busy_timeout.
Watch out for nested with blocks in Python. The with sqlite3.connect(...) context manager handles commit/rollback but does not close the connection. Nesting two with blocks on the same path keeps both connections alive:
with sqlite3.connect("db.sqlite3") as outer:
with sqlite3.connect("db.sqlite3") as inner: # second open connection
inner.execute("INSERT INTO t VALUES (1)")The second connection holds a write lock the first one can never acquire. Use contextlib.closing to ensure the connection itself closes, not just the transaction.
Confirm WAL is supported on your filesystem. WAL mode requires shared memory (mmap). On exFAT, FAT32, some encrypted FUSE filesystems, and certain WSL2 mounts, mmap either fails silently or returns inconsistent data. SQLite falls back to journal_mode=DELETE without telling you. Run PRAGMA journal_mode; after setting WAL; if it returns anything other than wal, the filesystem is the problem and you need to move the database to a real local filesystem.
Check the PRAGMA synchronous setting. If you have set PRAGMA synchronous=OFF for performance, a crash leaves a -wal file in an unrecoverable state and the next process sees a persistent lock. synchronous=NORMAL is the right setting for WAL mode; it is nearly as fast as OFF but recovers cleanly. Never run synchronous=OFF in production.
What Other Tutorials Get Wrong About SQLite Locking
Most SQLite tutorials list the same fixes but frame them in ways that produce subtle bugs or wasted time.
They recommend raising busy_timeout as the first fix. Bumping the timeout is the most-suggested and least-effective response to SQLITE_BUSY. A 30-second timeout for a lock held by a long-running transaction just delays the failure by 30 seconds. Enable WAL mode first, then identify whether the lock is contention (timeout helps) or a held transaction (timeout does not).
They suggest deleting the -wal and -shm files to “reset” the lock. This corrupts the database if any committed-but-not-yet-checkpointed transactions live in the WAL. Never delete those files while any process has the database open. The correct recovery is to open the database in a fresh process and run PRAGMA wal_checkpoint(TRUNCATE);.
They claim Python’s with sqlite3.connect(...) closes the connection. It does not. The with statement on a connection handles commit/rollback only; the connection itself stays open. Wrap with contextlib.closing or call conn.close() explicitly. This is the single most common Python-specific cause of persistent lock errors.
They omit the BEGIN DEFERRED vs BEGIN IMMEDIATE distinction. SQLite’s default BEGIN DEFERRED does not acquire the write lock until the first write statement. Two connections that both BEGIN, both SELECT, then both INSERT will produce SQLITE_BUSY on the second INSERT even though both appeared to be inside a transaction from the start. BEGIN IMMEDIATE makes the lock acquisition predictable.
They miss the network-filesystem failure mode. SQLite on NFS, SMB, Dropbox, Google Drive, or OneDrive does not work reliably; the file locking primitives are silently broken or differently implemented. Tutorials that show SQLite paths on shared drives without warning leave readers chasing phantom bugs that look like concurrency issues but are actually filesystem-level lock failures.
They confuse database is locked with database is busy. SQLITE_BUSY means another connection holds an incompatible lock; the retry back-off can resolve it. SQLITE_LOCKED is a different error that means the SAME connection holds an incompatible lock (typically reading from the same connection that is writing). They have different fixes; tutorials that treat them identically miss the latter case.
Frequently Asked Questions
Why does SQLite say “database is locked” when only one process is writing?
Almost always because of an unclosed connection or a held transaction within your own process. Python’s with sqlite3.connect() does NOT close the connection; ORMs that pool connections often leave one open with an uncommitted transaction. Use lsof mydb.sqlite3 (Linux/macOS) or handle.exe (Windows) to find every process that has the file open; you may find your own application appearing twice.
Will enabling WAL mode break anything?
For local-disk single-application use, no. WAL is strictly better in almost every case. The caveats: WAL requires shared memory (mmap), which fails on some encrypted FUSE filesystems and on FAT32/exFAT; WAL files can grow large if a long-lived read transaction prevents checkpointing; and WAL requires SQLite 3.7.0 or later. Verify with PRAGMA journal_mode; after setting it to confirm it stuck.
How many concurrent writers can SQLite handle?
One. That is the entire constraint. SQLite serializes all writes through a single lock. The “scaling” answer for SQLite is to ensure all writes flow through a single connection (or a serialized writer goroutine/thread) so they queue cleanly without lock contention. If you need real concurrent write throughput across processes, you need PostgreSQL or MySQL.
What is the difference between busy_timeout and the timeout connection parameter?
In Python’s sqlite3 module, timeout=5 in connect() calls sqlite3_busy_timeout(conn, 5000) under the hood. They are the same setting. In other languages, ?_busy_timeout=5000 in the DSN (Go’s mattn/go-sqlite3), { timeout: 5000 } (Node’s better-sqlite3), or PRAGMA busy_timeout = 5000; all reach the same C API.
Why does my SQLite database get locked when using Django?
Django defaults to ATOMIC_REQUESTS = False, but many projects enable it. With ATOMIC_REQUESTS = True, every HTTP request wraps in a transaction. If a view takes seconds (slow external API call, complex template render), it holds the write lock for the entire request. Disable ATOMIC_REQUESTS and manage transactions narrowly with transaction.atomic() around the specific queries that need them.
When should I move from SQLite to PostgreSQL?
When you have concurrent writers across processes (not just threads in one process), when you need replication, when your write throughput exceeds a few hundred writes per second sustained, or when your data lives on shared storage. SQLite is excellent for embedded apps, single-process services, read-heavy workloads with moderate writes, and local development. It is not designed for multi-process production write loads regardless of how high you set the busy timeout.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Peewee Not Working — Connection Pooling, Field Errors, and Migration Setup
How to fix Peewee errors — OperationalError database is locked, connection already open, field type mismatch, model meta database missing, N+1 queries, and peewee-migrate setup.
Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors
How to fix Tortoise ORM errors — Tortoise.init not called, no module imported model, fetch_related missing, aerich migration setup, FastAPI integration patterns, and ConfigurationError missing connection.
Fix: psycopg Not Working — psycopg2 to psycopg3 Migration, Connection Pool, and Async Errors
How to fix psycopg errors — psycopg2 to psycopg 3 import migration, connection pool setup, row factory tuple vs dict, COPY protocol changes, async psycopg pool, server-side cursor confusion, and binary mode performance.
Fix: asyncpg Not Working — Connection Pool, Prepared Statements, and Transaction Errors
How to fix asyncpg errors — connection refused localhost 5432, pool exhausted timeout, prepared statement does not exist, type codec not registered, JSON automatic conversion, and transaction rollback on exception.