How to Recover a Corrupted SQLite Database: A Real-World Walkthrough
If you have run SQLite in production for more than a few months, you have seen this error:
Error: database disk image is malformed (11)
Your first instinct is usually wrong. This post walks through a real corruption-and-recovery drill, verified against an actual database with the pure-Go modernc.org/sqlite driver. You will learn the recovery ladder, and — more importantly — what SQLite's integrity check cannot see.
Rule 1: stop writing, and copy the files
The moment you see malformed, stop the service. Do not keep writing to the database. Every write makes recovery harder.
Then make a copy before touching anything. In WAL mode a database is three files:
mydb.db # the main database
mydb.db-wal # pending writes not yet checkpointed
mydb.db-shm # shared-memory index (can be rebuilt, but copy it anyway)
Copy all three together — copying only mydb.db while a -wal file exists will silently lose every write that hasn't been checkpointed. On Windows, copy to a folder on the same volume first: copying across drives (for example C: → E:) and replacing in place raises WinError 17 (ERROR_NOT_SAME_DEVICE). Copy, then replace on the same volume.
Never operate on the live file. Work on the copy.
Step 1: characterize the damage
Run an integrity check on the copy:
PRAGMA integrity_check;
It returns one row per problem. A healthy database returns a single ok. The output tells you whether the corruption is structural (a broken page or B-tree) or cosmetic.
Step 2: try VACUUM INTO
VACUUM INTO 'clean.db' (SQLite 3.27+) writes a brand-new, defragmented, structurally valid database to a new file. It is the single most useful recovery command because it works even when the original refuses to open cleanly — and it is also a great backup method for a healthy database.
sqlite3 mydb-copy.db "VACUUM INTO 'recovered.db';"
Then verify the result:
PRAGMA integrity_check; -- on recovered.db -> should return: ok
What actually happened: three verified failure modes
To test this properly, I built a 1,000-row database, then deliberately broke three copies in three different ways. Here is the real output from the drill, generated with modernc.org/sqlite on Windows:
=== SQLite corruption + recovery walkthrough ===
driver: modernc.org/sqlite (pure Go), journal_mode=WAL, synchronous=NORMAL
healthy.db: 1,000 rows in table items(id, name, value, note)
--- Case 1: bit-rot (garbled bytes inside row payloads) ---
SELECT count(*): ok (1000 rows readable despite corruption)
PRAGMA integrity_check: 1 row(s) returned
[1] ok
VACUUM INTO: OK -> recovered.db integrity=true rows=1000
--- Case 2: truncation (power loss / partial write) ---
SELECT count(*): FAILED -> database disk image is malformed (11)
PRAGMA integrity_check: 1 row(s) returned
[1] integrity_check failed: database disk image is malformed (11)
VACUUM INTO: FAILED -> database disk image is malformed (11)
fallback: restore from backup, or use sqlite3 CLI '.recover'
--- Case 3: zeroed page (bad disk block) ---
SELECT count(*): FAILED -> database disk image is malformed (11)
PRAGMA integrity_check: 1 row(s) returned
[1] *** in database main ***
Tree 2 page 7: btreeInitPage() returns error code 11
VACUUM INTO: FAILED -> database disk image is malformed (11)
fallback: restore from backup, or use sqlite3 CLI '.recover'
Three takeaways from the drill:
- Structural corruption is loud. Truncation and zeroed pages break the file structure, so reads fail with
malformed (11),integrity_checkreports the specific page, andVACUUM INTOcannot save you. - Content corruption is silent. In Case 1 the flipped bytes landed inside row payloads —
integrity_checkreturnedokandVACUUM INTOproduced a valid 1,000-row copy. That copy is structurally perfect but its content is wrong. SQLite does not checksum payload bytes, sointegrity_checkwill not notice this. The only defense against silent bit-rot is a backup you can compare against. VACUUM INTOis not magic. When the structure itself is gone (Cases 2 and 3), it fails. This is exactly when your backup — or the CLI's.recovercommand — becomes the plan.
Step 3: when VACUUM INTO fails
Recovery ladder, in order:
- Restore from backup. This should be the outcome 99% of the time. If you are here, the rest of this list is damage control.
sqlite3 mydb-copy.db .recover | sqlite3 recovered.db— SQLite 3.29+ recovers as much data as it can from a badly damaged file by scanning raw pages. Some data is dropped; keys and page structure that cannot be parsed are skipped.- Last resort: set
PRAGMA writable_schema=ONand salvage individual tables. This is invasive — do it only to pull out a few irreplaceable rows.
Prevention is the actual fix
- Back up all three files together in WAL mode (
db+-wal+-shm), or useVACUUM INTOon a running database to take a safe, consistent snapshot. - Run
PRAGMA integrity_checkon a schedule. It is cheap for small databases and catches structural rot early — just remember it does not catch payload corruption. - Test your backups. A backup you have never restored is a wish, not a backup.
- Match your durability settings to your data.
synchronous=NORMALin WAL mode is a sensible default; for data you cannot lose, considerFULLand a redundant copy.
The short version
- Stop writing, copy
db+-wal+-shmto the same volume, work on the copy. PRAGMA integrity_checkto characterize;VACUUM INTOto rebuild what is structurally recoverable.- If
VACUUM INTOfails, restore from backup, then try.recover. integrity_checkwill not catch corrupted row contents — that is why backups matter.
Need to size a SQLite database before you build it? Use the estimator below.
Comments (0)
No comments yet.