Room database migrations in 2026: shipping schema changes without losing a row
A practical guide to Room database migrations on Android — AutoMigration, hand-written Migration objects, and how to test a migration before your users find the bug.
Every local-first app eventually needs a schema it didn’t ship with version 1. A new column, a renamed table, a field that used to be a String and needs to be an Int. The moment that happens, you’re one careless annotation away from deleting every user’s data on their next update. Room migrations exist to prevent that, and most of the pain people hit with them comes from skipping the parts that feel optional but aren’t.
The trap: fallbackToDestructiveMigration()
Room throws an IllegalStateException the instant your @Database version number goes up without a matching migration path. The fix that shows up in every Stack Overflow answer is one line:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
This compiles, ships, and works perfectly — in debug builds, on your own device, where you don’t mind losing test data. In production it means: the next time you bump the schema version, Room drops every table and recreates them empty. For Granyn, that’s someone’s two years of budget entries gone on an app update they didn’t ask for. There’s no dialog, no warning, no undo. It just quietly happens the next time the app opens. If this line exists anywhere outside a debug build config, treat it as a bug, not a convenience.
AutoMigration covers more than people assume
For the common cases — adding a column with a default value, adding or dropping a table, renaming a column — Room’s @AutoMigration can generate the migration for you from two schema snapshots:
@Database(
version = 2,
entities = [Entry::class],
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()
This only works if you’ve turned on schema export (room.schemaLocation in your Gradle config) so Room has a JSON snapshot of every version to diff against. Skip that setup and AutoMigration has nothing to compare, and you’ll find out at build time, not at 2am in a crash report. For a renamed or deleted column, you also need a small @RenameColumn or @DeleteColumn spec class pointing Room at the old and new names — it can’t infer intent from a diff alone.
Where AutoMigration stops helping
Anything that needs to reshape existing data — splitting one column into two, converting a stored String amount into cents as an Int, backfilling a new required field from other rows — is out of scope for AutoMigration. That’s not a limitation to work around; it’s the signal that you need a real Migration object with real SQL in it.
Writing the migration by hand
A manual Migration is just a version pair and a block of SQL you’re responsible for getting right:
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE entries ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'"
)
db.execSQL(
"UPDATE entries SET currency = (SELECT default_currency FROM user_prefs LIMIT 1)"
)
}
}
Register it alongside any AutoMigrations in .addMigrations(MIGRATION_2_3) on the database builder. SQLite’s ALTER TABLE is limited — no dropping columns, no changing a column’s type in place — so anything beyond adding a column usually means the classic three-step dance: create a new table with the shape you want, copy the data across with INSERT INTO ... SELECT, drop the old table, rename the new one. It’s more SQL than you’d like to write, and it’s exactly the SQL that has to be correct, because it runs once, silently, on every device on its next launch.
Test the migration, not just the destination schema
The mistake that ships the most bugs here isn’t the SQL — it’s never running it against a real, aged database. Room’s MigrationTestHelper exists for exactly this:
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrate2To3_preservesExistingRows() {
helper.createDatabase(TEST_DB, 2).apply {
execSQL("INSERT INTO entries (id, amount) VALUES (1, 4200)")
close()
}
helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3)
}
This builds a version-2 database, seeds it the way a real user’s device would look, then runs your migration against it and validates the resulting schema matches what Room expects. It catches the two failure modes that actually happen in production: a migration that works on an empty database but throws on one with rows, and a migration whose SQL is fine but whose resulting schema doesn’t match your entity definitions (Room checks this strictly, down to column order and default values).
The checklist
Before any schema version bump ships: schema export is on, every version jump has either an AutoMigration or a hand-written Migration registered, fallbackToDestructiveMigration() doesn’t appear outside test code, and at least one MigrationTestHelper test seeds real rows and asserts they survive. None of this is exciting work. It’s also the difference between a schema change nobody notices and a support inbox full of “my data disappeared” the morning after a release.
// Related reading
More from the journal
Jetpack Glance in 2026: building a home-screen widget that never lies about your data
A practical guide to Jetpack Glance widgets on Android — state, click actions, and the update-quota trap that makes home-screen widgets show stale data.
Building OldSchool's adherence calendar: turning dose taps into a day you can trust
A monthly adherence calendar looks like a single colored dot per day. Here's the event model, the day-status rules, and the on-time math behind it, on-device.
Building Subly: the calendar math behind subscription renewal dates
Predicting a subscription's next charge date sounds trivial until you hit month-end billing, leap years, and trial conversions. Here's how Subly gets it right, on-device.