Fixing "database is locked" in SQLite

Posted by Kyle Hankinson July 21, 2026


Your app writes to SQLite and gets this back:

sqlite3.OperationalError: database is locked

From the CLI, the same failure looks like:

Error: stepping, database is locked (5)

The 5 is the part worth reading. It is SQLITE_BUSY, and it means one specific thing: a different connection holds a lock that your statement needs. SQLite locks at the level of the whole database file, not rows or tables, so one writer anywhere blocks every other writer everywhere. Nothing is corrupt and nothing has been lost. Another connection simply has the file at the moment yours wants it.

Its lesser-known sibling SQLITE_LOCKED (code 6) is a conflict inside a single connection, such as dropping a table that one of your own open statements is still reading. The two are separate problems with separate fixes; the result codes reference has the full catalogue. If you see code 5, look outward at other connections.

Reproduce it on purpose

The error is much less mysterious once you can trigger it at will. Open two terminals on the same file. In the first, start a write transaction and leave it hanging:

-- terminal A
sqlite> CREATE TABLE t(x INTEGER);
sqlite> BEGIN IMMEDIATE;
sqlite> INSERT INTO t VALUES (1);
-- no COMMIT yet

In the second, try to write:

-- terminal B
sqlite> INSERT INTO t VALUES (2);
Error: stepping, database is locked (5)

Terminal B fails instantly, and the instantly is itself a clue. Out of the box, SQLite does not wait for a contended lock at all:

sqlite> PRAGMA busy_timeout;
0

Verified on SQLite 3.50.6: the default busy timeout is zero milliseconds. Any contention, however brief, surfaces as an immediate error.

Find out who holds the lock

Before reaching for fixes, identify the writer. The usual suspects, roughly in order of likelihood:

  • An open transaction in your own code. A BEGIN whose COMMIT lives on a code path that an exception skipped is the classic version.
  • A second connection inside the same app. Connection pools, background workers, and ORMs that quietly open extra handles all count as "another connection" even though it is all your process.
  • Another program with the file open: a sqlite3 session in a forgotten terminal tab, a backup or sync agent, or a database browser. GUI clients are connections too. If you keep the file open in SQLPro for SQLite while your app writes to it, the app and the browser are two clients of one database, exactly like terminals A and B above.
  • A crashed process that died mid-write, leaving a hot -journal file behind.

On macOS and Linux, lsof names every process holding the file. Run against the two-terminal repro:

$ lsof app.db
COMMAND   PID          USER   FD   TYPE DEVICE SIZE/OFF      NODE NAME
sqlite3 68988 kylehankinson    3u   REG   1,14    12288 360183043 app.db

PID 68988 is terminal A. In a real incident this is the fastest way to discover the forgotten CLI session or the second app instance you did not know was running.

Set a busy timeout

Since the default is to fail immediately, the single highest-value change is to tell SQLite to wait:

PRAGMA busy_timeout = 5000;

With that set, a blocked statement retries until the lock frees or five seconds elapse. Rerunning the blocked INSERT from terminal B while terminal A still held its transaction, the statement returned after 5.3 seconds of waiting instead of failing at once. If the other writer finishes inside the window, your statement proceeds and no error is ever seen; per the busy_timeout documentation, the handler sleeps repeatedly until the accumulated wait reaches the limit.

Two details matter. The setting is per connection, so it belongs in the code that opens connections, not in a one-off console session. And it is a treatment rather than a cure: it absorbs short contention, but a transaction held open for minutes will still time out everyone else.

Keep write transactions short, and start them honestly

Since one writer blocks all others, the length of your write transactions sets the amount of contention everyone else experiences. Batch your work, commit promptly, and never hold a transaction across user think-time or network calls.

When a transaction is going to write, declare that up front with BEGIN IMMEDIATE rather than a plain BEGIN. It claims the write lock at BEGIN, so contention surfaces at the very first statement, where retrying is trivial, instead of halfway through a batch of work. Short transactions are also one of the bigger wins covered in how to improve SQLite insert performance.

Enable WAL mode

In the default rollback-journal mode, readers and writers get in each other's way in a manner that surprises most people: a reader merely holding a read transaction open will block a writer's COMMIT. Reproduced on 3.50.6:

-- terminal A (journal_mode = delete, the default)
sqlite> BEGIN;
sqlite> SELECT count(*) FROM t;
-- read transaction stays open

-- terminal B
sqlite> BEGIN IMMEDIATE; INSERT INTO t VALUES (9); COMMIT;
Error: stepping, database is locked (5)

Switch the database to write-ahead logging and the same sequence just works:

sqlite> PRAGMA journal_mode = WAL;
wal

With WAL enabled, the writer in terminal B committed while terminal A's read transaction stayed open, and a reader querying during an uncommitted write got a clean answer from the last committed state. Readers no longer block the writer and the writer no longer blocks readers, which eliminates the largest class of SQLITE_BUSY errors in mixed read/write workloads.

WAL mode has been available since SQLite 3.7.0 (2010) and is a persistent property of the database file: set it once and every future connection gets it. Two things to know before flipping the switch. The database gains -wal and -shm companion files, which is normal. And WAL still allows only one writer at a time, so two concurrent writers can still collide; keep the busy timeout even after enabling it.

What not to do

Do not delete the -journal or -wal files to "unlock" a database. Those files are not clutter. The journal holds the information needed to roll back an interrupted transaction, and the WAL file holds committed transactions that have not yet been merged into the main file. Deleting either one can corrupt the database or silently discard committed data. If a stale lock genuinely outlived its process, which is rare on a local disk, the fix is to make sure no process has the file open (check lsof again) and then open it normally; SQLite recovers hot journals by itself.

And resist wrapping every write in an unbounded retry loop. Retries paper over the symptom while the leaked connection or forgotten transaction underneath keeps growing. Five seconds of busy timeout plus a diagnosis of who actually held the lock beats an infinite loop every time.


Tags: SQLite

About the authorKyle Hankinson is the founder and sole developer of SQLPro Studio and the Hankinsoft Development suite of database tools. He has been building native macOS and iOS applications since 2010.

Try SQLPro Studio — A powerful database manager for MySQL, PostgreSQL, Microsoft SQL Server, SQLite, Oracle, and Snowflake. Available on macOS, iOS, and Windows.

Download Free Trial View Pricing Compare