dbcheck check a file "disk image is malformed" empty backup deleted rows "database is locked" integrity_check says ok

“Database is locked” tells you nothing about the file — and repairing it is the wrong move.

“Database is locked” is a statement about a lock, not about a file. In the runs below the identical message arrived after 0.00 s and after 2.00 s; nothing in the text says which one you got, who holds the lock, or whether the file is intact. It is also not a corruption message: the same database that raised it passed a byte-level check unchanged.

What was measured, on this machine (SQLite 3.45.1)

WAL  reader (timeout=0) select count(*)              -> (500,)                           0.00s
WAL  writer (timeout=0) insert                       -> database is locked               0.00s
WAL  writer (timeout=2) insert                       -> database is locked               2.00s
DELETE + BEGIN EXCLUSIVE, reader (timeout=0)         -> database is locked               0.00s
open a 0444 file read-write and insert               -> attempt to write a readonly database

Two things follow. First, in WAL mode a reader is never blocked by a writer — it returned all 500 rows while the write transaction was open — so “locked” on a read usually means the file, not the rows: a read-only mount, a missing -shm, or a handle on a different filesystem. Second, the wait is the only thing that changed between the first and second writer: with timeout=2 the same statement waits the full two seconds and then gives up with the same words. The message is a timeout report wearing the costume of a diagnosis.

Two different errors share one sentence

SQLite has SQLITE_BUSY (“database is locked”) and SQLITE_LOCKED (“database table is locked”). They are different codes with different causes, and people fix the wrong one. The second one appears without any second process at all:

BEGIN IMMEDIATE; insert; PRAGMA wal_checkpoint(passive)
  -> database table is locked

That is one connection, one file, no contention — the checkpoint cannot run inside the write transaction it is part of. No amount of retrying fixes it; moving the checkpoint out of the transaction does.

The fix is a retry budget, and it is measurable

Four processes, 40 inserts each, into one WAL database:

busy_timeout=0s  -> ok=[40,  0,  0,  0]  errors=[0, 40, 40, 40]  rows=540  wall=0.16s
busy_timeout=5s  -> ok=[40, 40, 40, 40]  errors=[0,  0,  0,  0]  rows=660  wall=0.54s

Same code, same file, same contention: 120 of 160 writes lost to the message versus none, and the only change is how long a blocked writer is willing to wait. If your application treats “database is locked” as “the database is damaged”, it will throw away 75% of its writes and then send the file to a repair tool.

“Locked” is not “corrupt” — check which one you have

The 20 KB database that raised the errors above, inspected byte by byte right after:

page_size=4096  journal_mode=WAL  header_pages=5  file_pages=5  tail=0 pages
freelist=0  change_counter=2  valid_for=2
·  WAL_MODE_CLEAN: normal for a cleanly closed database, all commits are in this file   => OK

The three page counts agree, there is no residue past the declared end, the log is clean. A lock lives in the process table, not in the file, so no file checker can see it — and a repair tool run against a locked-but-healthy database will rewrite the header and, in WAL mode, checkpoint and delete the -wal, which is the only copy of the newest commits.

The cases where “locked” really is a file/reader problem

A snapshot taken while the app was running — app.db (4 KB) plus its app.db-wal (12,392 bytes), but no -shm — placed on read-only media:

open mode=ro   on a read-only dir, no -shm   -> unable to open database file
open mode=ro&immutable=1, same two files     -> no such table: t

dbcheck on the pair:
  page_size=4096  header_pages=1  file_pages=1   -wal=12392 frames=3 commit_pages=2
!! MAIN_DB_STALE: the newest commit lives in the -wal (it sets db size to 2), but this .db
   still says 1 page — copying only the .db loses those commits        => CRITICAL

The -shm is a shared-memory index that WAL needs in order to open the log; without write access to the directory, SQLite cannot create it and answers “unable to open database file” — a message nobody associates with a lock. The usual advice for read-only media, immutable=1, skips the log entirely and hands you an empty database that reports no such table: three hundred committed rows, present on disk, invisible to the reader. That failure looks exactly like corruption and is not.

Order of operations

  1. Read the code, not the text: SQLITE_BUSY means wait and retry with a real busy_timeout (and keep transactions short); SQLITE_LOCKED means your own connection is fighting itself.
  2. If a reader is locked in WAL mode, stop looking at transactions — look at the directory: is it writable, does the -shm exist, is this a snapshot on read-only media?
  3. Only then inspect the bytes. If a -wal is present, check .db and -wal together before restoring or repairing anything.

What was not tested, stated plainly: no NFS or SMB mount is available on this machine, so the network-filesystem variants of this error (mmap of the -shm failing across hosts, stale attribute caching, lock daemons) were not reproduced here and are not claimed. Everything above is a local run on one ext4 filesystem, and the commands are short enough to repeat on your own share.

Check one .db file (max 20 MB; add the -wal if you have it)

Read-only byte inspection: page counts, WAL commit, freelist residue. The file is deleted before the answer is written, and nothing about its content is logged.