> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# SQLite Read the Row. Why Is the Update Still Locked?
- URL: https://debugly.dev/sqlite-busy-snapshot-wal-read-to-write/
- Published: 2026-11-09T01:55:00.000Z
- Updated: 2026-11-09T01:55:00.000Z
- Description: An SQLite WAL read can succeed and the next write can fail. Use the extended error code to distinguish an old snapshot from a writer lock.
- Author: Rohit Bhadani
- Tags: Databases, Concurrency, Debugging

The SQLite query reads a row just fine. Then the update says `database is locked`, even though the other connection has already committed. Before raising `busy_timeout`, check the extended error code. In WAL mode, `SQLITE_BUSY_SNAPSHOT` means your transaction is trying to write from an old read snapshot. Waiting won't make that snapshot current. Roll back, start a new transaction, and repeat the read and the decision that depended on it.

*This is an AI-prepared, documentation-based explainer, not a personally reproduced incident. The two-connection walkthrough is illustrative. See the* [*editorial policy*](https://debugly.dev/editorial-policy/)*.*

## The writer left. Your snapshot stayed.

Imagine a worker reading a job before deciding whether to claim it. Connection A issues `BEGIN` and reads the job. Connection B updates it and commits. A still sees the snapshot it started reading, which is exactly what [SQLite’s WAL isolation rules](https://sqlite.org/isolation.html?ref=debugly.dev) promise. But if A now tries to write, allowing that old snapshot to become the basis of a new write would fork the database history. SQLite refuses the upgrade.

There doesn't have to be another writer holding the lock at that point. That's the useful distinction: a writer that's still active can finish while you wait; a snapshot that's already old stays old until you end the transaction. Five more seconds doesn't give it a time machine.

## Make the two connections take turns

Use two SQLite shells on the same disposable, local database file. In shell A, create the table, enable WAL, and start a read transaction:

```
PRAGMA journal_mode=WAL;
CREATE TABLE jobs (id INTEGER PRIMARY KEY, attempts INTEGER);
INSERT INTO jobs VALUES (1, 0);
PRAGMA busy_timeout=5000;
BEGIN;
SELECT attempts FROM jobs WHERE id = 1;
```

In shell B, execute this statement and let it commit. If you started an explicit transaction in that shell, commit it before returning to A.

```
UPDATE jobs SET attempts = attempts + 1 WHERE id = 1;
```

Back in shell A, try the same update without closing A's read transaction. The extended result is [SQLITE\_BUSY\_SNAPSHOT](https://sqlite.org/rescode.html?ref=debugly.dev#busy%5Fsnapshot), numeric code 517\. The displayed error may just say `database is locked`, depending on the client. Inspect the driver's extended code rather than guessing from that string. In Python 3.11 and later, for example, an error originating in SQLite can expose [sqlite\_errorcode and sqlite\_errorname](https://docs.python.org/3/library/sqlite3.html?ref=debugly.dev#sqlite3.Error). In a real application, check WAL mode on the affected database and trace where the first read happened. A default deferred transaction can start with that read, long before the line that throws.

## Retry the decision, or reserve the writer before reading

If the transaction is safe to retry, roll it back and begin again. Read the current row and recheck the claim condition. Retrying just the final update can replay a decision made against an old state, even if your framework has already opened a fresh transaction. Keep the retry bounded and keep irreversible side effects, like sending a notification, out of the retried transaction body unless they're explicitly deduplicated.

If you know the transaction must read and then write, [BEGIN IMMEDIATE](https://sqlite.org/lang%5Ftransaction.html?ref=debugly.dev) starts a write transaction before the read. If another writer already has it, the begin itself can fail with `SQLITE_BUSY`. That moves contention to a point where you haven't made any decisions from a read yet. The tradeoff is that you're holding the writer slot during the reads, so keep the transaction short and leave network calls outside it. When one conditional `UPDATE` can express the whole decision, consider doing that as a standalone write instead of splitting the check and write into separate steps.

A [busy timeout](https://sqlite.org/c3ref/busy%5Ftimeout.html?ref=debugly.dev) is useful for some lock contention, but it doesn't repair a stale snapshot. Test both cases separately: hold a writer open for the contention test; commit the competing write before A tries to upgrade for the snapshot test. Assert that the retry rereads the row before it acts. Debugly's [PostgreSQL transaction retry walkthrough](https://debugly.dev/postgres-40001-serialization-failure-retry/) makes a related point about rerunning a decision, though its database and error contract are different.