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

PRAGMA integrity_check said ok. There are no tables in the file.

Six files, one machine, SQLite 3.45.1. PRAGMA integrity_check answered ok to a 0-byte file, to a 4 KB copy of a database whose 500 rows had all been committed into a -wal that was never copied, and to a file with one byte changed inside a text column. It answered database disk image is malformed to exactly one of the six: the file that was simply truncated.

“PRAGMA integrity_check answers one question: are the pages in this file consistent with each other. It returns ok for a 0-byte file, and for a backup whose every table lives in a -wal you left behind. It cannot tell you that the file is not the database.”

Measured on SQLite 3.45.1, 2026-11-09. Script to reproduce all six files below.

What it said, and what was actually in the file

FilePRAGMA integrity_checkWhat the file really held
0 bytes — cp of a file that had not been created yet ok 0 tables. select count(*) from sqlite_master → 0
4,096 B — cp live.db backup.db while a WAL-mode app was running, 500 rows committed ok sqlite_master → []; select count(*) from orders → no such table: orders
8,192 B — same copy, but one commit had already been checkpointed: the live database held 101 rows ok select count(*) from users → 100. The last commit is gone and nothing anywhere reports an error
651,264 B — a 160-page database with its last page cut off database disk image is malformed caught. This is the only one of the six the pragma gets right
4,096 B of zeros file is not a database caught by the open itself, before the pragma runs
655,360 B — one byte changed inside a 200-byte text cell (offset 20,700) ok 3,000 rows still read; one row's text is now different from the original, and no query notices

Why ok is the honest answer

A SQLite file states its own size in three places: the 4-byte page_count field at offset 28, the length of the file, and — when a -wal exists — the database size written by the last commit frame. integrity_check verifies that the pages inside the file agree with each other. A 0-byte file has no pages to disagree with, so it is consistent. A one-page stub whose tables were committed into a -wal is a correct one-page file; every byte in it is valid. Both are green.

This is why the standard advice — open the backup, run PRAGMA integrity_check, trust the answer — passes exactly the files you need to worry about. The failure is not that the pragma is wrong. It is that it was asked the wrong question: is this file self-consistent, not is this file the database. The two questions have different answers whenever a -wal is involved, whenever the copy is a file that was never written, and whenever the damage is inside a cell rather than in the page structure.

The check that answers the other question, without opening the file

Four numbers decide it, and all four come out of the first 100 bytes plus two stat calls:

import os, struct, sys
p = sys.argv[1]
h = open(p, "rb").read(100)
if len(h) < 100 or h[:16] != b"SQLite format 3\x00":
    sys.exit("%s: no SQLite header (%d bytes)" % (p, os.path.getsize(p)))
ps = struct.unpack(">H", h[16:18])[0] or 65536
declared = struct.unpack(">I", h[28:32])[0]
actual = (os.path.getsize(p) + ps - 1) // ps
print("%s: page_size=%d journal=%s declared_pages=%d file_pages=%d wal=%d B"
      % (p, ps, "WAL" if h[18:20] == b"\x02\x02" else "rollback",
         declared, actual, os.path.getsize(p + "-wal") if os.path.exists(p + "-wal") else 0))

Run on the six files above, it prints:

empty.db: no SQLite header (0 bytes)
a.db:   page_size=4096 journal=WAL      declared_pages=1   file_pages=1   wal=0 B
b.db:   page_size=4096 journal=WAL      declared_pages=2   file_pages=2   wal=0 B
trunc.db: page_size=4096 journal=rollback declared_pages=160 file_pages=159 wal=0 B
zeros.db: no SQLite header (4096 bytes)
clean.db: page_size=4096 journal=rollback declared_pages=160 file_pages=160 wal=0 B

Read it in this order: no header → the copy captured nothing, or captured the wrong file. journal=WAL with wal=0 B and a small file_pages → the rows are in a -wal that is not here. declared_pages greater than file_pages → truncation, the one case the pragma also catches. All three numbers agreeing on a file you copied by hand still only means internally consistent: it does not mean it is the copy you wanted.

Reproduce all six files

python3 - <<'EOF'
import shutil, sqlite3, os
os.makedirs("/tmp/six", exist_ok=True); os.chdir("/tmp/six")
def ro(p, sql):
    c = sqlite3.connect("file:%s?mode=ro" % p, uri=True)
    try: return c.execute(sql).fetchall()
    except Exception as e: return "%s: %s" % (type(e).__name__, e)
    finally: c.close()

# 1) 0-byte file
open("empty.db", "wb").close()
print("empty.db", ro("empty.db", "PRAGMA integrity_check"))

# 2) all 500 rows committed into the -wal, only the .db copied
c = sqlite3.connect("live.db"); c.execute("PRAGMA journal_mode=WAL")
c.execute("CREATE TABLE orders(id INTEGER PRIMARY KEY, amount INT)")
c.executemany("INSERT INTO orders(amount) VALUES(?)", [(i*7,) for i in range(500)])
c.commit(); shutil.copyfile("live.db", "stub.db")
print("stub.db", os.path.getsize("stub.db"), "B wal", os.path.getsize("live.db-wal"),
      ro("stub.db", "PRAGMA integrity_check"), ro("stub.db", "select count(*) from orders"))
c.close()

# 3) truncated: 160 pages cut to 159, header still says 160
c = sqlite3.connect("big.db"); c.execute("PRAGMA journal_mode=DELETE")
c.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, payload TEXT)")
c.executemany("INSERT INTO t(payload) VALUES(?)", [("x"*200,) for _ in range(3000)]); c.commit(); c.close()
shutil.copyfile("big.db", "cut.db")
open("cut.db", "r+b").truncate(os.path.getsize("big.db") - 4096)
print("cut.db", os.path.getsize("cut.db"), "B", ro("cut.db", "PRAGMA integrity_check"))
EOF

What this checker does, and does not, add

The same six files, run through the checker linked at the bottom of this page (subprocess, JSON on stdout):

empty.db     0 B  verdict=critical   NOT_SQLITE
a.db     4,096 B  verdict=critical   WAL_MODE_BUT_NO_WAL
b.db     8,192 B  verdict=critical   WAL_MODE_BUT_NO_WAL
trunc.db 651,264 B verdict=critical  HEADER_LONGER_THAN_FILE
zeros.db 4,096 B  verdict=critical   NOT_SQLITE
bitflip.db 655,360 B verdict=ok       (nothing found)

Honest limits, because the interesting part is where the checks disagree:

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.