Room Cannot Verify Data Integrity — Migration Fix
Add a Migration with the missing ALTER TABLE and bump the version.
20+ years shipping production backend systems. Everything here is grounded in real deployments.
- ✓Basic Android development with Kotlin
- ✓Room entities, DAOs, and database builders
- ✓Reading logcat crash logs
- Room hashes your entity schema into the database file and compares it at startup, so any entity change without a Migration crashes with IllegalStateException
- Bump the version number and register Migration(oldVersion, newVersion) with the exact ALTER TABLE or CREATE TABLE statements the change needs
- Don't ship fallbackToDestructiveMigration in release builds, it drops every table and recreates them, wiping user data silently
- Turn on exportSchema, diff the JSON snapshots to derive correct migration SQL, and verify upgrades with MigrationTestHelper before release
Think of Room as a librarian with a strict catalog. Every shelf layout gets a fingerprint written in a logbook. One day you add a new shelf without updating the logbook, and the librarian refuses to open the library because the fingerprint no longer matches. A Migration is the signed note that says shelf 5 was added on Tuesday, here's where every book moved. Without that note, the doors stay shut.
You add one field to an entity, run the app on your phone, and everything looks perfect. Then release day arrives and the crash reports flood in: IllegalStateException, cannot verify the data integrity, hundreds of users stuck on the launch screen. Your phone worked because it installed fresh. Their phones failed because they carried the old database, and Room refused to open it.
That's the core tension behind this error. Room trusts the schema it wrote into the database file more than it trusts your code. When the two disagree and no Migration explains the gap, it crashes on purpose rather than risk corrupting user data. It's a safety feature that feels like a betrayal at 9 AM on release day.
The stakes are higher than a normal crash. A failed migration blocks the entire app, not one screen, and panicked workarounds like fallbackToDestructiveMigration trade the crash for silent data loss. Users don't forgive empty libraries, logged-out sessions, or vanished settings.
This guide walks you through the full fix: reading the hash mismatch, writing a manual Migration with correct SQL, knowing when AutoMigration is safe, and using exported schemas to stop guessing. You'll leave with a workflow that makes this crash nearly impossible to ship again.
Why Room Crashes Instead of Guessing Your Schema
Room writes an identity hash of your entities into the database file the first time it creates it. On every later open it recomputes the hash from your current entity classes and compares the two. If they match, the database opens. If they differ, Room looks for a Migration registered for that exact version hop. When none exists, it throws IllegalStateException with both hash values and refuses to open the file.
This strictness is deliberate. SQLite would happily let you read a table whose columns no longer match your data class, returning nulls or wrong types silently. Room chose the loud failure: a crash you notice beats corruption you don't. The hash comparison runs inside RoomOpenHelper before any DAO query executes, which is why the whole app dies on the launch screen instead of one screen misbehaving.
Fresh installs never hit this path because there's no old database file and therefore no stored hash to disagree with. Room simply creates tables from the current entities. That's the trap: your development phone installs clean every time, while every existing user carries the old hash. If your QA process only tests fresh installs, this crash is invisible until release.
The version number on your @Database annotation is what activates the check. Bumping it tells Room a new schema era has begun. Forgetting the Migration to accompany it guarantees the crash for every upgrader. Treat the version bump and the Migration object as one atomic change that must ship together in the same commit.
Writing a Manual Migration With Correct SQL
A manual Migration is a small class that bridges exactly one version hop. You declare Migration(3, 4), override migrate(), and execute the SQL that transforms the old schema into the new one. Room runs these in order at startup, so a user jumping from version 2 to 4 gets Migration(2, 3) then Migration(3, 4) applied in sequence. Each hop must exist or the chain breaks.
For added columns the SQL is ALTER TABLE with the exact column name, type, and nullability from your entity. A non-null Kotlin String needs NOT NULL plus a DEFAULT, because existing rows have no value for the new column and SQLite won't leave them empty. Get the default wrong and the migration throws; get the type wrong and the next hash check still fails. Copy these details from the exported schema JSON rather than memory.
New tables use CREATE TABLE IF NOT EXISTS with every column and primary key from the entity, and new indices use CREATE INDEX. Complex reshapes, like splitting one table into two, need a four-step dance: create the new table, copy rows with INSERT INTO ... SELECT, drop the old table, rename. Write these against a real copy of the old database, never against an empty one.
Registration matters as much as SQL. Passing the Migration to addMigrations() is what connects it to the version hop. A perfect Migration class sitting unregistered in your codebase fixes nothing. Keep migrations in one file, named by version pair, so reviewers can see at a glance which hops are covered.
fallbackToDestructiveMigration and the Data It Eats
When Room can't find a migration path, fallbackToDestructiveMigration tells it to delete every table and recreate them from the current entities. The crash disappears. The app launches. And every row the user ever saved is gone: accounts, settings, offline content, all of it. The Play Console shows a clean crash graph while your support inbox fills with data-loss tickets you can't reverse.
Developers reach for it because it makes the error vanish during development, where wiping a test database costs nothing. The danger is that one builder chain gets copied into release code, or a well-meaning hotfix adds it to stop the crash reports. From that moment every user whose migration is missing loses data instead of seeing an error. Silent destruction is strictly worse than a loud crash.
There's a narrower variant, fallbackToDestructiveMigrationFrom(2, 3), that limits destruction to specific old versions you've deliberately abandoned. That's acceptable when documented, for example dropping support for a beta schema nobody should still carry. Even then, prefer a real migration when the old version ever shipped to production users.
The safe pattern is build flavors: enable the fallback only when BuildConfig.DEBUG is true, and add a lint check or code-review rule that rejects it in release source sets. Your tests should then treat any missing-migration crash as a gift, proof the safety net caught a bug before users paid for it.
AutoMigration: What It Handles and Where It Drops Data
AutoMigration lets Room generate the migration SQL itself for simple changes: new tables, new columns, and not much else. You declare AutoMigration(from = 4, to = 5) in the @Database annotation and Room compares the exported schemas to produce the statements. For purely additive changes it works well and removes hand-written SQL entirely.
The catch is renames and deletions. Room can't tell a rename from a drop-plus-add by comparing schemas, so without guidance it drops the old column and creates an empty new one. Your users' names, saved in the name column, vanish while fullName arrives blank. The @RenameColumn spec exists to close that gap: it tells Room the values must be carried over. Deletions need @DeleteColumn for the same reason, making the intent explicit and reviewable.
Type changes, table splits, and data backfills are beyond AutoMigration's reach. When a column changes from Int to String or rows must be transformed during the hop, write a manual Migration. You can mix both styles in one database version: auto for the additive parts, manual for the tricky hop. Room applies them together as declared.
AutoMigration still needs exported schemas to function, since it diffs the JSON snapshots at compile time. And it still needs tests: run the autoMigration path through MigrationTestHelper with seeded rows to confirm renamed data actually survives. An annotation that compiles but maps the wrong column is a silent data-loss bug wearing a safe disguise.
exportSchema Diffing: Deriving Migration SQL Exactly
Setting exportSchema = true and pointing room.schemaLocation at a schemas directory makes Room write one JSON file per database version on every build. Each file records tables, columns, types, defaults, primary keys, foreign keys, and indices exactly as Room understands them. Commit these files to version control: they're the authoritative history of every schema you ever shipped.
The workflow is simple. After changing an entity, rebuild and run diff between the old and new JSON files. The diff output is your migration spec: added columns become ALTER TABLE statements, new tables become CREATE TABLE, removed indices become DROP INDEX. Because the JSON reflects Room's own compile-time view, the SQL you derive matches what the hash check expects. No guessing at types from Kotlin code.
These snapshots also make code review meaningful. A pull request that touches entities should include the new schema JSON, and reviewers can see the precise database impact without running the app. If a version bump arrives without a matching JSON file or Migration, that's a reject. Many teams enforce this with a CI check that fails when the schemas directory doesn't change alongside entity files.
Keep every historical JSON file, not just the latest. MigrationTestHelper consumes them to recreate the exact databases your users carry, which is what makes upgrade tests faithful. A migration validated against a hand-built schema is a guess; one validated against the exported snapshot is proof.
Testing Migrations Before They Reach Users
MigrationTestHelper is the harness that proves an upgrade works before users depend on it. You create a database at the old version, insert representative rows, run your Migration, and assert the data survives with the new schema in place. The helper validates the post-migration schema against your current entities, so both data preservation and hash agreement are checked in one test.
Good tests use realistic data: names with unicode, long strings, boundary numbers, and nulls in every nullable column. Edge-case rows are what expose wrong defaults and type mismatches. Cover every shipped version as a starting point, not just the latest, because a user three releases behind must migrate through the whole chain. One test per hop keeps failures pinpointed.
Run these as instrumented tests in CI on every pull request that touches entities, DAOs, or the database class. They execute against real SQLite on a device or emulator, which catches behaviors unit tests on the JVM miss. A failing migration test should block the merge exactly like a failing API test would.
Finally, test the unhappy paths. Verify that opening the database without the Migration throws, confirming your safety net is intact. Verify downgrade behavior if your app supports it. The goal is a test suite where the only way this crash reaches production is by deliberately ignoring a red build.
The One-Column Update That Locked Every Existing User Out
- Fresh-install testing can't catch migration bugs. Every release with an entity change must be tested as an upgrade from the previous shipped database with real rows in it.
- fallbackToDestructiveMigration converts a loud crash into silent data loss, which is worse. Gate it behind debug builds and fail code review on any release usage.
- Exported schema JSON files are the contract between releases. Diff them to write migrations, commit them for history, and assert against them in automated tests.
| File | Command / Code | Purpose |
|---|---|---|
| logcat.txt | E/AndroidRuntime: FATAL EXCEPTION: main | Why Room Crashes Instead of Guessing Your Schema |
| AppDatabase.kt | @Database(entities = [User::class], version = 4, exportSchema = true) | Writing a Manual Migration With Correct SQL |
| DatabaseBuilder.kt | val debugDb = Room.databaseBuilder(context, AppDatabase::class.java, "app.db") | fallbackToDestructiveMigration and the Data It Eats |
| AppDatabase.kt | @Database( | AutoMigration |
| build.gradle.kts | android { | exportSchema Diffing |
| MigrationTest.kt | @Test | Testing Migrations Before They Reach Users |
Key takeaways
Common mistakes to avoid
5 patternsBumping the version number without adding a Migration object
Shipping fallbackToDestructiveMigration to silence the crash
Writing ALTER TABLE statements that don't match the entity
Trusting AutoMigration for renames and deletions without a spec
Skipping exportSchema so migrations are written blind
Interview Questions on This Topic
What triggers Room's cannot verify data integrity crash?
Frequently Asked Questions
20+ years shipping production backend systems. Everything here is grounded in real deployments.
That's Android. Mark it forged?
6 min read · try the examples if you haven't