Chapter 09 · Concurrency, Locking, WAL Mode, Busy Handling, and Checkpoints
SQLITE_BUSY, busy_timeout, Retries, and Short Transactions
Treat SQLITE_BUSY as observable contention: configure bounded connection-level waiting, distinguish SQLITE_BUSY from SQLITE_LOCKED, shorten write transactions, and retry only operations designed to be retry-safe.
Learning outcomes
A lock conflict is useful information: another connection currently prevents this operation from proceeding. Reliable applications turn that fact into a bounded policy. They do not retry forever, and they do not use a 60-second timeout to hide transactions that should last 20 milliseconds. This lesson separates SQLITE_BUSY from SQLITE_LOCKED, configures connection-level waiting, and measures how transaction duration changes the result.
Distinguish SQLITE_BUSY from SQLITE_LOCKED using SQLite’s documented meanings.
Configure a finite busy timeout in the CLI, PRAGMA interface, and driver layer.
Explain why a database connection has one busy handler policy at a time.
Measure timeout behavior instead of assuming a number fixed the workload.
Design bounded retries only for retry-safe/idempotent operations.
Reduce contention by shortening transaction hold time and acquiring write intent deliberately.
SQLITE_BUSY and SQLITE_LOCKED are not synonyms
Many drivers render both as some form of “locked,” so applications should preserve SQLite’s result code when possible. The distinction guides diagnosis.
| Result | Practical meaning | Typical first response |
|---|---|---|
SQLITE_BUSY | The database operation conflicts with activity on a different database connection, often in another process. | Wait according to a bounded busy policy, or fail/retry the logical operation later. |
SQLITE_LOCKED | The conflict is within the same connection, or with another connection participating in shared-cache locking. | Fix same-connection statement/lifecycle misuse or investigate shared-cache behavior; a busy timeout is not the generic cure. |
A classic BUSY case is connection A holding a write transaction while connection B attempts another write. A classic LOCKED-style case is trying to change schema/table state on a connection while another active statement on that same connection prevents it. Shared cache adds specialized locking cases and is not a recommended default concurrency strategy for this course.
Busy timeout: bounded waiting on one connection
SQLite provides a busy handler mechanism. sqlite3_busy_timeout() installs a handler that sleeps/retries until the configured accumulated waiting time is reached; after that, the blocked operation returns SQLITE_BUSY. A connection can have only one busy handler, so replacing the handler/timeout replaces the previous busy policy.
-- sqlite3 shell command: milliseconds.timeout 750-- SQL/driver-accessible PRAGMA: millisecondsPRAGMA busy_timeout = 750;PRAGMA busy_timeout;The shell’s .timeout and PRAGMA busy_timeout are convenient teaching tools. Production drivers often expose their own connection option or API; use that when available so configuration is explicit in connection initialization.
Setting a timeout on connection B does not configure connection A, future pooled connections, or every process opening the file. Initialize and verify the policy for each connection according to your driver.
A timeout buys time; it does not create write concurrency
If A holds the writer for 2 seconds and B is willing to wait 500 ms, B still fails. If A finishes in 50 ms, the same 500 ms budget may be enough. The important variable is often transaction hold time, not the largest timeout you can tolerate.
bad shape A: BEGIN -------- HTTP call -------- user wait -------- UPDATE -------- COMMIT B: |--------------------- BUSY wait ---------------------X better shape A: HTTP call ---- prepare values ---- BEGIN -- UPDATE -- COMMIT B: waits briefly -> succeeds
Measure a conflict with two writers
This Python lab uses two independent connections to one disposable file. The first transaction deliberately holds the writer. The second has a 300 ms timeout, so its failure duration should be roughly bounded by that policy rather than hanging indefinitely.
import sqlite3, timefrom pathlib import Pathpath = Path("busy-lab.db")path.unlink(missing_ok=True)setup = sqlite3.connect(path)setup.execute("CREATE TABLE counter(id INTEGER PRIMARY KEY, value INTEGER NOT NULL)")setup.execute("INSERT INTO counter VALUES(1,0)")setup.commit()setup.close()A = sqlite3.connect(path, timeout=0, isolation_level=None)B = sqlite3.connect(path, timeout=0.300, isolation_level=None)A.execute("BEGIN IMMEDIATE")A.execute("UPDATE counter SET value=value+1 WHERE id=1")t0 = time.monotonic()try: B.execute("BEGIN IMMEDIATE")except sqlite3.OperationalError as exc: elapsed = time.monotonic() - t0 print(type(exc).__name__, round(elapsed, 3), exc) # expected: a lock/busy error after roughly the configured wait budgetfinally: A.rollback() A.close(); B.close()Exact timing varies with scheduler and platform. The meaningful observation is bounded behavior: B does not silently wait forever.
Shorten the transaction and watch the outcome change
Now use two threads solely as a test harness. Writer A holds its transaction for 100 ms; B allows 750 ms. Because A releases the writer before B’s budget expires, B can acquire the writer and commit.
import sqlite3, threading, timepath = "busy-lab.db"def writer_a(): con = sqlite3.connect(path, timeout=0, isolation_level=None) con.execute("BEGIN IMMEDIATE") con.execute("UPDATE counter SET value=value+1 WHERE id=1") time.sleep(0.10) # deliberate test hold con.execute("COMMIT") con.close()def writer_b(): con = sqlite3.connect(path, timeout=0.75, isolation_level=None) t0 = time.monotonic() con.execute("BEGIN IMMEDIATE") waited = time.monotonic() - t0 con.execute("UPDATE counter SET value=value+1 WHERE id=1") con.execute("COMMIT") print("B waited", round(waited, 3), "seconds") con.close()a = threading.Thread(target=writer_a)a.start(); time.sleep(0.02)b = threading.Thread(target=writer_b)b.start(); a.join(); b.join()check = sqlite3.connect(path)print(check.execute("SELECT value FROM counter WHERE id=1").fetchone()[0])# expected after the two successful increments: 2check.close()BEGIN IMMEDIATE can make a busy failure easier to reason about
SQLite documents that SQLITE_BUSY can arise when a transaction starts, during writes, or at commit. If BEGIN IMMEDIATE succeeds, the connection has already established the write transaction, which moves ordinary competing-writer failure toward the start of the unit. This often simplifies application error handling compared with doing several reads and then discovering at the first write that another writer won.
WAL can return the extended SQLITE_BUSY_SNAPSHOT when a transaction first reads an older snapshot and later tries to upgrade that stale read transaction into a writer after another connection has committed. Starting with BEGIN IMMEDIATE requests the write transaction before establishing that read-then-upgrade pattern.
Retries need a logical contract
A retry is safe only when repeating the operation cannot create a second business effect. Chapter 8 introduced stable request IDs. Bring that design into busy handling.
| Operation | Blind retry? | Reason |
|---|---|---|
| Read-only report | Usually safe after transient BUSY. | No database side effect, assuming surrounding external state is not part of the contract. |
Idempotent command keyed by stable request_id | Can be safe with bounded retry. | Database can detect an already-committed logical request. |
| “Charge account / send message / call API” inside retry loop | Unsafe without additional design. | External effects may already have happened even if the database step is retried. |
| UPDATE guarded by version predicate | Retry requires re-read/re-evaluation. | A zero-row optimistic-concurrency miss is a business conflict, not merely BUSY. |
A bounded retry skeleton
Prefer a driver’s native busy timeout first. If the application adds higher-level retries, bound both attempts and total elapsed time, reuse the same request identity, and include jitter/backoff so many clients do not wake in lockstep.
deadline = monotonic_now() + 2.0attempt = 0while monotonic_now() < deadline and attempt < 4: attempt += 1 try: execute_same_idempotent_request(request_id) return success except SQLITE_BUSY: sleep_with_bounded_backoff_and_jitter(attempt)return retryable_failure_to_callerDo not copy this pseudocode without mapping your driver’s exceptions and transaction cleanup. A failed attempt must leave its connection in a known state before reuse.
When a larger timeout makes the system worse
If the real cause is a transaction that waits for user input or performs a slow network call, a long timeout can convert an immediate, diagnosable failure into a queue of blocked workers. That increases latency, memory/socket occupancy, and the chance of request pileups. Fix transaction scope first; then choose a timeout that matches the workload’s latency budget.
Two-writer lab checklist
- Start with
busy_timeout=0and reproduce the collision. - Measure a finite timeout and record elapsed wait.
- Reduce A’s transaction hold time; do not merely increase B’s timeout.
- Repeat with
BEGIN IMMEDIATEat the application boundary. - Log the resolved DB path, connection ID, transaction ID/request ID, BEGIN time, COMMIT/ROLLBACK time, and SQLite result code.
- Close both connections and delete only the disposable test database.
Busy-handling checkpoint
Choose the engineering response, not the most convenient setting.
- What practical difference separates SQLITE_BUSY from SQLITE_LOCKED?
- Does PRAGMA busy_timeout configure every future connection?
- Why can a 30-second busy timeout hide a design problem?
- What transaction change often helps more than increasing the timeout?
- Why must application-level retries preserve the same logical request identity?
- What should happen to a failed attempt’s transaction before the connection returns to a pool?
Review the answers
SQLITE_BUSY normally means another database connection conflicts; SQLITE_LOCKED normally means a same-connection or shared-cache conflict. Busy timeout is per connection/handler policy. Huge waits can hide long transactions and create request queues. Shortening the write transaction and moving slow external work outside are primary fixes. Stable request identity prevents retries from becoming duplicate commands. A failed attempt must be committed only if valid or explicitly rolled back/otherwise normalized before reuse.
Production judgment and bridge
Busy handling is not a throughput feature; it is a contention policy. Keep writes short, make retries bounded and idempotent, and retain actual result codes. Lesson 3 changes the journal architecture to WAL so readers can keep stable snapshots while a writer commits—but the single-writer rule remains.