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

A file that opens, exits 0, and contains none of your data is the normal outcome, not the exception.

“SQLite backup is empty” and “database disk image is malformed” are usually the same bug seen from two sides: the file you have is not the database you think you have — and every tool you tried opened it, said nothing, and exited 0.

That silence is not an accident of your setup. SQLite answers one question when it opens a file (“do the internal page counts agree?”), and a file whose tables were committed into a -wal that was never copied passes that question happily. Four real cases, all reproduced on this machine:

What you didWhat you gotWhat sqlite said
cp app.db backup.db while a WAL-mode app was running 4 KB, 1 page, live database had 440 rows opens fine, sqlite_master is empty, “no such table: users”
copied app.db + app.db-wal after a crash the .db looks worse (1 page), the data is in the other file 500 rows readable — keep the pair
kept the file, deleted rows from it 3 free pages still hold 200 readable strings (longest 24 B) select count(*) = 0, the old rows are still on disk
restored a 52 KB backup that is actually complete three page counts agree, no residue past the declared end 440 rows, integrity_check ok

The three page counts

A SQLite file describes its own size in three independent places: the header field page_count, the actual file length, and — when a WAL exists — the dbsize written by the last commit frame. Checking that they agree with each other is what PRAGMA integrity_check does. It returns ok on a file whose tables live entirely in a -wal that is not there, because nothing in that file is inconsistent: it is just not the whole database.

Why opening it can destroy the evidence

A tool that opens a WAL database “read-only” still checkpoints it on close and deletes the -wal. If your only copy of the newest commits is that file, the act of inspecting it is the act of losing it. So this checker never calls sqlite3 — it reads bytes, walks the freelist chain with bounds and cycle guards, and counts the readable strings left in free pages.

No file of your own to try? This 4 KB file is a real “backup” taken with cp from a live WAL database that had 440 rows in it. It opens without error, it contains no tables, and every tool that looks at it says it is fine.

Before you repair anything

  1. Copy the .db and the -wal (and -shm if present) to a different disk. Do not “clean up” the side files — they are the data.
  2. Run the check on the copy. A verdict of critical / MAIN_DB_STALE means the pair is fine and you were about to throw away the half that matters.
  3. Only then restore or repair. sqlite3 old.db ".backup new.db" (or VACUUM INTO) produces a single self-contained file; cp does not.

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.

One-line check — no browser, same endpoint, JSON out

The request body is the file. Answer is a stable dbcheck/1 document, so it can go straight into a script or a monitoring job:

curl -sS --data-binary @backup.db \
  -H 'Content-Type: application/octet-stream' \
  https://huxin.online/dbcheck/check

If you still have the -wal, send both parts. Order matters, not the field names: the first file part is the .db, the second is its -wal.

curl -sS -F 'file=@backup.db' -F 'wal=@backup.db-wal' \
  https://huxin.online/dbcheck/check

Read verdict (ok / warn / critical) and the findings[].code strings; exit_code is 0 only when the verdict is ok. Limits are enforced, not advisory: 20 MB for the .db, 4 MB for the -wal, 10 s of wall clock, 2 audits at a time — the third gets 429 BUSY. The upload is deleted before the answer is written and no byte of it is logged. Capabilities: api/info.