Fixing Python sqlite3 OperationalError on Concurrent Writes
You run your script, two threads both try to write to the same SQLite database at the same moment, and Python throws sqlite3.OperationalError: database is locked. Your process crashes, your data is incomplete, and the fix is not obvious from the error message alone.
SQLite is not MySQL or PostgreSQL. It has a file-level locking model that behaves differently under concurrent access, and Python's sqlite3 module adds its own layer of threading restrictions on top. Understanding both is the key to getting this right.
What You'll Learn
- Why SQLite raises
OperationalErrorwhen multiple writers hit the database simultaneously - How to tune connection timeouts so writers wait instead of crash
- How WAL mode dramatically improves concurrent write throughput
- How to share a connection across threads safely using a
threading.Lock - How to design transactions that hold locks for the shortest possible time
Prerequisites
- Python 3.8 or later (the
sqlite3module is in the standard library) - Basic familiarity with threads (
threadingmodule) or multiprocessing - A SQLite database file you control β this guide does not require any external dependencies
What Causes the OperationalError on Concurrent Writes
SQLite uses a file-level lock. When one connection writes data, it acquires an exclusive lock on the entire database file. Any other connection that tries to write at the same time immediately receives a SQLITE_BUSY status from the C library, which Python surfaces as sqlite3.OperationalError: database is locked.
This can happen in three common scenarios:
- Multiple threads each open their own connection and write concurrently.
- Multiple processes (e.g., two instances of a script, or a web server with multiple workers) share the same database file.
- A single connection is shared across threads without synchronization β which Python also forbids by default.
The default behavior in Python's sqlite3 module is to raise the error immediately with zero retry logic. You have to opt into waiting or restructure your code to avoid the conflict entirely.
How SQLite Locking Works (The Short Version)
SQLite progresses through a sequence of lock states: UNLOCKED β SHARED β RESERVED β PENDING β EXCLUSIVE. Readers only need a SHARED lock. Writers start at RESERVED (they signal intent to write) and then escalate to EXCLUSIVE when they flush changes to disk.
In the default rollback journal mode, a writer holds an EXCLUSIVE lock for the entire duration of the transaction flush. Every other writer β and even readers in some cases β must wait. In WAL (Write-Ahead Logging) mode, writers append changes to a separate WAL file and only briefly lock during a checkpoint, allowing readers to continue uninterrupted and allowing one writer at a time to proceed without blocking readers at all.
Knowing this tells you exactly which levers you have: reduce lock duration, switch to WAL, or serialize access yourself.
Fix 1: Set a Connection Timeout
The simplest fix is telling the connection to wait instead of fail immediately. The sqlite3.connect() function accepts a timeout parameter (in seconds) that controls how long it will retry before raising OperationalError. The default is 5 seconds, but many developers never realize it exists or that it can be tuned higher.
import sqlite3
# Wait up to 30 seconds for the lock to clear before giving up
conn = sqlite3.connect("app.db", timeout=30)
This one-liner handles burst contention gracefully. If your writes are short and bursty, a timeout of 15β30 seconds means a waiting writer will almost always succeed rather than crash. It is not a complete solution on its own β if you have sustained, heavy write traffic, the lock will never clear and every writer will eventually time out β but it eliminates most casual concurrency bugs with a single argument change.
For related connection and query issues in SQLite, see the article on fixing stale data after a commit in Python sqlite3, which covers how Python's isolation level interacts with what you read back after a write.
Fix 2: Enable WAL (Write-Ahead Logging) Mode
WAL mode is the single most effective fix for concurrent SQLite access. It separates reads from writes at the storage level so readers never block writers and writers never block readers. Multiple readers can run simultaneously, and one writer can proceed while readers continue without any lock conflict.
import sqlite3
def get_connection(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, timeout=30)
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
conn.commit()
return conn
Run PRAGMA journal_mode=WAL; once per connection right after opening it. The mode is persistent β once set on a database file, new connections will use WAL automatically β but it costs nothing to set it again and doing so makes your code self-documenting.
The synchronous=NORMAL pragma is a safe companion setting. It reduces fsync calls compared to the default FULL setting, improving write throughput while still protecting against OS crashes. Only synchronous=OFF risks data loss on power failure.
Note: WAL mode creates two extra files alongside your database:
app.db-walandapp.db-shm. These are normal and expected. Do not delete them while any connection is open.
Even with WAL mode, only one writer at a time can proceed. If you have multiple concurrent writers, they will still queue up. The improvement is that readers never contend with writers, which is usually the bigger bottleneck in real applications.
Fix 3: Use check_same_thread=False Safely With a Lock
By default, Python's sqlite3 module raises ProgrammingError: SQLite objects created in a thread can only be used in that same thread if you try to share a connection across threads. This is a safety guard, not a hard SQLite limitation.
You can disable this check with check_same_thread=False, but you must then add your own synchronization. A threading.Lock is the right tool:
import sqlite3
import threading
class ThreadSafeDB:
def __init__(self, db_path: str):
self._conn = sqlite3.connect(
db_path,
timeout=30,
check_same_thread=False,
)
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.row_factory = sqlite3.Row
self._lock = threading.Lock()
def execute_write(self, sql: str, params: tuple = ()) -> None:
with self._lock:
self._conn.execute(sql, params)
self._conn.commit()
def execute_read(self, sql: str, params: tuple = ()) -> list:
with self._lock:
cursor = self._conn.execute(sql, params)
return cursor.fetchall()
def close(self):
self._conn.close()
The lock ensures only one thread accesses the connection at a time. Every read and write goes through it. This pattern works well for a long-running service (a Flask dev server, a background worker) where you want a single persistent connection rather than opening and closing one per request.
If you have many threads that read frequently, consider giving each thread its own connection (all in WAL mode) and only sharing a lock for write operations. That way reads scale across threads without contention.
Fix 4: Scope Transactions as Narrowly as Possible
The longer a transaction stays open, the longer the lock is held, and the more likely another writer will collide with it. A common mistake is opening a transaction, doing some application logic (API calls, file I/O, user prompts), and then committing β leaving the lock held for seconds while nothing database-related is happening.
import sqlite3
import time
conn = sqlite3.connect("app.db", timeout=30)
conn.execute("PRAGMA journal_mode=WAL;")
# BAD: lock is held during the sleep (simulates slow application logic)
conn.execute("BEGIN")
conn.execute("INSERT INTO events (name) VALUES (?)", ("start",))
time.sleep(5) # another writer will block or fail here
conn.execute("COMMIT")
# GOOD: do application logic first, then write atomically
event_name = "start" # imagine this came from a slow API call
with conn: # context manager commits on exit, rolls back on exception
conn.execute("INSERT INTO events (name) VALUES (?)", (event_name,))
Using with conn: as a context manager is both safer and clearer. It commits automatically on a clean exit and rolls back on any exception, and it keeps the transaction scoped tightly around the actual SQL work.
If you are dealing with batch inserts, use executemany() inside a single transaction rather than committing after each row. One commit for a thousand rows is dramatically faster and holds the lock for a much shorter total duration than a thousand individual commits.
rows = [(f"item_{i}",) for i in range(1000)]
with conn:
conn.executemany("INSERT INTO items (name) VALUES (?)", rows)
Also see the guide on fixing duplicate rows on every script run in sqlite3 β tight transaction scoping is also the right tool for avoiding double-inserts when a script restarts.
Fix 5: Serialize Writes Through a Single Writer Thread
If you have many threads that all need to write and WAL mode still produces contention, the most robust architectural fix is a dedicated writer thread. Every other thread submits work to a queue; the writer thread processes them one at a time. This guarantees zero write conflicts because only one entity ever holds a write lock.
import sqlite3
import threading
import queue
class WriterThread(threading.Thread):
def __init__(self, db_path: str):
super().__init__(daemon=True)
self._db_path = db_path
self._queue: queue.Queue = queue.Queue()
def run(self):
conn = sqlite3.connect(self._db_path, timeout=30)
conn.execute("PRAGMA journal_mode=WAL;")
while True:
item = self._queue.get()
if item is None: # shutdown signal
break
sql, params, result_event = item
try:
with conn:
conn.execute(sql, params)
result_event.set() # signal success to the caller
except Exception as exc:
result_event.exc = exc
result_event.set()
conn.close()
def write(self, sql: str, params: tuple = ()) -> None:
event = threading.Event()
event.exc = None
self._queue.put((sql, params, event))
event.wait() # block until the writer thread is done
if event.exc:
raise event.exc
def stop(self):
self._queue.put(None)
self.join()
# Usage
writer = WriterThread("app.db")
writer.start()
writer.write("INSERT INTO logs (msg) VALUES (?)", ("hello",))
writer.stop()
Each calling thread blocks on event.wait() until its write completes, so it is still synchronous from the caller's perspective. If you need fire-and-forget semantics, just skip the event.wait() call and drop the result event entirely.
If you want to understand how Python's sqlite3 module handles query results more broadly, the article on fixing sqlite3 queries that return no rows despite matching data covers several connection-state issues that are easy to hit in threaded code.
Common Pitfalls to Avoid
Opening connections inside a tight loop
Each sqlite3.connect() call opens a new file handle and starts a new transaction context. If you open a connection, insert one row, and immediately close it inside a loop that runs hundreds of times per second, you are creating and destroying connections at a rate that introduces contention and significant overhead. Open one connection, reuse it, and close it when the work is done.
Forgetting to commit
Python's sqlite3 module opens an implicit transaction for DML statements (INSERT, UPDATE, DELETE). If you never call commit(), changes are never written and the pending transaction holds a lock indefinitely. Always use with conn: or call conn.commit() explicitly. This is a common cause of the "database is locked" error even with a single writer β the previous connection was never properly closed or committed.
Mixing WAL and non-WAL connections
If one process opens the database in WAL mode and another opens it without setting the pragma, the second will still use WAL (since the mode is stored in the file), but may behave unexpectedly if it is not aware of the WAL files. Be consistent: set PRAGMA journal_mode=WAL; on every connection you open.
Using SQLite for high-write multi-process workloads
SQLite's own documentation recommends considering a client-server database (PostgreSQL, MySQL) when you have many concurrent writers from separate processes. If your application already has more write traffic than WAL mode + serialization can handle, migrating to PostgreSQL is a real option. The article on fixing slow PostgreSQL queries past a million rows is a useful read when you make that transition.
Ignoring the isolation_level parameter
When you set isolation_level=None on a connection, you switch to autocommit mode. Every statement is committed immediately, which is faster for single writes but means you lose transactional protection for multi-statement operations. Know which mode you are in and be explicit about it rather than inheriting the default behavior by accident.
Wrapping Up
Concurrent write failures in SQLite almost always come down to one of three things: no retry patience, the wrong journal mode, or unsynchronized shared connections. Here are the concrete steps to take right now:
- Add
timeout=30(or higher) to everysqlite3.connect()call so writers wait instead of crash on brief contention. - Enable WAL mode with
PRAGMA journal_mode=WAL;immediately after opening each connection β this is the highest-impact single change you can make. - Use
with conn:as your standard transaction pattern to keep lock duration minimal and handle rollbacks automatically. - If sharing a connection across threads, add a
threading.Lockand setcheck_same_thread=False; if write throughput is still a bottleneck, route all writes through a single dedicated writer thread with a queue. - If you have sustained high-concurrency write requirements that SQLite genuinely cannot serve, plan for PostgreSQL rather than fighting the file-lock model indefinitely.
Frequently Asked Questions
Why does Python sqlite3 raise 'database is locked' even with just two threads?
SQLite uses a file-level exclusive lock for writes, so the second thread to attempt a write gets SQLITE_BUSY immediately if the first thread's transaction has not committed yet. Setting a timeout on the connection and enabling WAL mode are the two fastest fixes.
Does enabling WAL mode allow truly parallel writes to SQLite?
No β WAL mode still only allows one writer at a time. The key improvement is that readers and writers no longer block each other, which eliminates most real-world contention. For genuinely parallel write workloads you need a client-server database like PostgreSQL.
Is it safe to set check_same_thread=False in Python sqlite3?
It is safe as long as you add your own synchronization, typically a threading.Lock around every connection call. Without a lock, concurrent thread access through a single connection can corrupt the connection's internal state.
How long should I set the sqlite3 connection timeout for concurrent writes?
A timeout of 15 to 30 seconds handles most burst-write scenarios without the caller waiting too long. If writers are consistently timing out at 30 seconds, the contention is structural and you need WAL mode, a writer queue, or a different database engine.
Will WAL mode files cause problems if I copy or back up the SQLite database?
Yes, if you copy the .db file without also copying the -wal and -shm files while connections are open, you may get an incomplete or inconsistent backup. The safest approach is to run PRAGMA wal_checkpoint(FULL) and close all connections before copying, or use SQLite's online backup API.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!