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 did | What you got | What 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
- Copy the
.dband the-wal(and-shmif present) to a different disk. Do not “clean up” the side files — they are the data. - 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.
- Only then restore or repair.
sqlite3 old.db ".backup new.db"(orVACUUM INTO) produces a single self-contained file;cpdoes 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.