Guides

Recovering a corrupt SQLite database in your browser

When SQLite reports "database disk image is malformed", one page in a b-tree stopped agreeing with its neighbors — and the fix that actually gets your rows back is not integrity_check but a raw page walk that re-inserts every decodable row into a clean file.

The error, and what it is really telling you

You open a database and the client stops cold:

Error: database disk image is malformed

The same condition surfaces as SQLITE_CORRUPT in the C API, as SqliteException: SQLite Error 11 in wrappers, and as a flat "malformed" from the sqlite3 shell. Whatever the phrasing, the engine followed a pointer inside the file and landed on something that cannot be a valid page, so it stops rather than hand you bytes it can't vouch for.

That strictness is why the file often looks worse than it is: a single damaged page can make an entire table unreadable while every other page sits there intact. Recovery's job is to read the pages directly and rescue what survived, which starts with knowing what a SQLite file is.

What "malformed" means for a b-tree database

A SQLite database is one file divided into fixed-size pages (commonly 4096 bytes each). The first page holds the header and the schema. Every table and index is a b-tree: a branching structure of pages pointing to other pages, ending in leaf pages that hold the actual rows. A row lives inside a page as a cell, carrying its own length, column types, and values.

One malformed page in a b-tree is enough to make a table unreadable through queries, while the surrounding pages stay perfectly decodable.

"Malformed" is what SQLite says when the pointers stop agreeing with the data: a branch page points to a child that isn't a valid b-tree page, a cell claims a payload longer than the page can hold, the freelist loops, or a page's type byte means nothing. SQLite treats any one of these as grounds to declare the whole file corrupt, even though the damage is usually localized: the bad page and anything downstream of it in that one b-tree are in doubt, while the rest of the file is ordinary, decodable data.

How SQLite files actually get corrupted

SQLite is robust, and most real-world corruption traces back to a handful of mechanisms rather than a flaky engine. Which one you hit shapes what you can expect to get back.

Power loss or a crash mid-write

A write in flight when the machine lost power, the process was killed, or the container was torn down can leave a page half-updated. Rollback-journal and WAL modes are designed to survive this, but only if the journal or log and its fsync guarantees survived too. On storage that lies about flushing, that safety net is gone and you get a torn page.

A missing or mismatched -wal file

In WAL mode, the newest committed data may live in the -wal sidecar file, not yet folded back into the main database. Copy the .sqlite on its own and you strand those commits; pair it with a -wal from a different point in time and it's worse. Either way it reads as corruption, because the two halves no longer describe the same database.

Copying a live database without its WAL

Plain-copying a database another process is actively writing captures an inconsistent snapshot: the copy catches some pages before a transaction and some after. It looked fine when copied and fails the moment it's opened elsewhere. Use SQLite's backup API or VACUUM INTO for live databases instead of a raw copy.

Two writers that shouldn't be

SQLite coordinates access through file locks. Put the file on a network share where locking is unreliable, or let two processes with separate locking assumptions write at once, and updates interleave into a state no single writer would produce. Networked filesystems are the recurring offender, which is why SQLite's docs warn against them.

How .recover-style salvage actually works

The reflex is to reach for PRAGMA integrity_check. That's worth running, but be clear about what it does: it reports the damage, without fixing anything or getting your rows out. For that you want the approach the sqlite3 shell's .recover command takes, and it works nothing like a normal query.

Instead of trusting the b-tree pointers, salvage walks the file page by page. For every page that looks like a table leaf, it reads each cell directly and decodes the row: rowid, column serial types, and values. Every decodable row gets re-inserted into a brand-new, empty database built from whatever schema can be reconstructed. Damaged pages are skipped; readable pages give up their rows whether or not the tree that indexed them survived.

Rows whose parent table can't be determined aren't thrown away: they land in a table conventionally named lost_and_found, keyed by the page and cell they came from, so you can inspect and re-sort them by hand. The output is a fresh file that opens cleanly, holding every row that was decodable in the original.

That's the honest ceiling. Salvage recovers what survived on disk; rows on the bad page, or on downstream pages that never got parsed, are not reconstructed. A SQLite file has no redundancy to regenerate them from, so a row that isn't decodable anywhere is simply gone. Recovery rebuilds a clean database around the data that lived; it never invents data that didn't.

As far as we're aware, no client-side browser tool has offered this before. Salvage of this kind has meant a command line and a local SQLite build, ruling it out for anyone who can't, or shouldn't, put the file on a server first. Running the page walk in the browser closes that gap: same technique, no upload.

Recovered output is always suspect

A salvaged database opens and queries cleanly, which tempts you to treat it as authoritative. Resist that: the page walk decodes raw bytes without the constraints a healthy database enforces, so the output carries quirks worth checking:

  • Resurrected deletions. A row you deleted lives on its page until that space is reused, and salvage can't tell a live row from a deleted-but-not-overwritten one, so deleted records can reappear.
  • Type shifts. Each value is stored with a serial type. Decoded under an unexpected one, an integer can come back as a blob or a number's precision can shift. Spot-check columns whose types matter.
  • Orphans in lost_and_found. Rows in lost_and_found arrive without their original table or a reliable order; you'll need the schema and your own knowledge of the data to route them home.
  • Broken invariants. Foreign keys, unique constraints, and triggers were bypassed during re-insertion, so a recovered database can hold combinations the original schema would have rejected.

Treat a recovered database as a high-quality lead, not a verdict: verify it against known-good records before anything downstream depends on it.

Handling a sibling -wal file

Before concluding the database itself is damaged, look for a -wal file next to it (and its companion -shm). In WAL mode, the most recent committed transactions can still be sitting in that log, not yet checkpointed into the main file. Recover without them and you'll miss exactly the newest data you care about most.

Keep the trio together: the .sqlite, the -wal, and the -shm, copied from the same moment. Opened together by a healthy SQLite, the log gets applied and the database may prove consistent after all, no salvage needed. When it's genuinely corrupt, a salvage that reads the WAL too can decode the committed pages held there. The opposite risk: a -wal from a different database, or a stale one from an old session, manufactures corruption when paired with the wrong file. If a sidecar's provenance is uncertain, salvage the database on its own and compare.

FAQ

What does "database disk image is malformed" mean?

It means SQLite followed a pointer inside the file and found something that can't be a valid database page: a b-tree cell pointing off the end of a page, a page whose type byte is nonsense, a freelist that loops, or a row whose payload length disagrees with its header. SQLite raises SQLITE_CORRUPT the moment it can't trust the structure. It names a symptom, not a cause, and won't tell you how many rows are affected.

Can a corrupt SQLite database be recovered?

Usually a large fraction of it, yes. Each table and index is a b-tree spread across fixed-size pages, and a single bad page rarely destroys the others. A .recover-style salvage walks the raw pages, decodes every cell it can parse, and re-inserts those rows into a fresh database, plus a lost_and_found table for rows whose parent table couldn't be identified.

Should I run recovery on the original file?

Never on your only copy. Make a byte-for-byte copy first and salvage from that, so a failed attempt costs you nothing. If a -wal or -shm file sits next to the database, copy those too, since they may hold the most recent committed changes. Salvage writes a brand-new output file and leaves the input untouched, but a spare copy is still cheap insurance.

Why does recovered data sometimes look wrong?

Because salvage decodes whatever bytes are physically in each cell, without the checks a healthy database enforces. Deleted rows can reappear, values can come back as the wrong type, and orphaned rows land in lost_and_found with no reliable order. Recovered output is always suspect: verify it against known-good records rather than trusting it outright.

Related reading: is it safe to upload your files to an online repair tool?