Room Migrations You Can Actually Roll Back
A bad database migration edits every user's data remotely with no undo. Why fallbackToDestructiveMigration belongs in debug only, how to test migrations against realistic data, and the additive-first strategy that keeps a rollback possible.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — There's no rollback for a schema migration: once a user's database is upgraded, the old
data is gone. Keep fallbackToDestructiveMigration() out of release builds, commit your exported
schemas, test every migration with MigrationTestHelper against realistically shaped data, and prefer
additive changes — because adding a column is reversible in practice and dropping one is not.
Why migrations are a different class of risk
Most bugs are recoverable. Ship a broken screen, fix it next release, users see the fix.
A migration runs once per device, mutates the user's data in place, and then that device moves on. If your migration drops a column that turned out to matter, the data is gone from every device that already ran it — including devices that will never install another update.
You are not changing a schema. You are performing an irreversible edit on every user's data simultaneously, remotely, with no undo.
Failure 1: destructive fallback in production
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration() // schema mismatch → DROP every table
.build()
This is in more production apps than anyone would like to admit, because it makes a development crash go away and nobody remembers to remove it. Then a migration is missed, and instead of crashing in QA the app silently wipes the user's data in the field.
Restrict it to debug:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.apply { if (BuildConfig.DEBUG) fallbackToDestructiveMigration() }
.build()
In release, a missing migration should crash loudly in your testing — a crash you catch is infinitely better than data loss you don't.
Failure 2: a migration never run against real data
Your test database has three rows you inserted by hand. A real user has forty thousand rows, a null in
a column you assumed was always populated after 2021, an emoji in a TEXT field, and a row written by
an app version from three years ago.
Migrations that pass on toy data and fail on real data are the norm, not the exception.
Export and commit your schemas
// build.gradle.kts
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
sourceSets["androidTest"].assets.srcDir("$projectDir/schemas")
Commit the generated JSON. Two reasons:
- It's the only record of what actually shipped at each version — invaluable during an incident.
MigrationTestHelperneeds it to construct a database at an old version.
Reviewing the schema diff in a PR is also the single most effective review step for database changes.
A NOT NULL appearing without a default is obvious in the JSON and easy to miss in the entity.
Test the migration, not the compilation
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
)
@Test
fun migrate6To7_keepsExistingNotes() {
helper.createDatabase(TEST_DB, 6).apply {
execSQL("INSERT INTO notes (id, title, body) VALUES ('1', 'Title', 'Body')")
execSQL("INSERT INTO notes (id, title, body) VALUES ('2', 'No body', NULL)") // the realistic row
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 7, true, MIGRATION_6_7)
db.query("SELECT title, archived FROM notes WHERE id = '1'").use {
assertTrue(it.moveToFirst())
assertEquals("Title", it.getString(0))
assertEquals(0, it.getInt(1)) // new column has its default
}
}
Assert on data, not just that the migration ran. runMigrationsAndValidate checks the schema
matches; only your assertions check that the rows survived intact.
Include at least one awkward row: a null, an empty string, a very long value, a unicode string.
Test the multi-version path
Users skip releases. Someone opens the app after eight months and jumps from version 3 to version 7, running four migrations back to back.
@Test fun migrateAll() {
helper.createDatabase(TEST_DB, 1).apply { seedRealisticData(); close() }
helper.runMigrationsAndValidate(TEST_DB, LATEST_VERSION, true, *ALL_MIGRATIONS)
}
This catches the classic compound bug: migration 5 adds a column, migration 6 renames it, and migration 7 assumes the original name. Each pairwise test passes; the chain fails.
Additive first — it's the only rollback you get
You can't undo a migration, but you can design so that a bad release is survivable:
- Adding a nullable column, or one with a default — safe. Old code ignores it; new code uses it. If you roll the app back, the column is simply unused.
- Adding a table — safe, same reasoning.
- Dropping or renaming a column — irreversible. Old data is gone.
- Changing a column's type — irreversible, and SQLite's flexible typing means the failure can be silent rather than a crash.
So the strategy that preserves optionality: add the new column, write to both for a release, migrate readers, and drop the old column only once you're confident. It's two releases instead of one, and it means a rollback costs nothing.
The checklist
Before any schema change ships:
fallbackToDestructiveMigration()is debug-only.- Schemas are exported and committed; the JSON diff is in the PR.
MigrationTestHelpertest with realistic rows, asserting on data.- Full-chain migration test from the oldest supported version.
- Destructive changes deferred to a follow-up release.
Five items, one of which is a habit rather than work. Cheap insurance against the one bug class you genuinely cannot fix after the fact.