Room database migrations are one of those Android development tasks that feel trivial right up until a release crashes on launch for every existing user. You add a column, bump the version number, everything works on your emulator because you reinstalled the app, and then production users hit an IllegalStateException the moment Room opens the database. In this post I'll walk through how I handle schema changes in Kotlin apps today: exporting schemas, choosing between AutoMigration and hand-written SQL, testing migrations properly, and avoiding the fallback settings that quietly delete user data.
I've worked on Android apps for 8+ years, including several years as a Senior Android Engineer at CodeBrew Labs, and in my freelance client work I regularly inherit codebases where the database layer was treated as an afterthought. Migrations are where that shows up first.
Why Room Database Migrations Break Production Apps
Room validates the on-disk schema against what your entities say it should be. Every time you change an @Entity, whether it's a new column, a renamed field, a new index or a changed nullability, the expected schema changes. If the device has an older database and Room can't find a path from the old version to the new one, it refuses to open the database.
The failures I see most often fall into a few buckets:
- Version bumped, no migration provided. The classic crash on launch.
- Migration provided, but the SQL doesn't match the entity exactly. A missing
NOT NULL, a wrong default value, or a forgotten index makes Room's validation fail with a long "Expected / Found" message. - Skipped versions. You tested 4 to 5, but a user who hasn't updated in months jumps from 2 to 5 and hits a gap.
- Data-level bugs. The schema validates, but the migration copied data into the wrong columns or dropped rows. No crash, just silently broken data, which is worse.
The root problem is that your development workflow almost never exercises the upgrade path. Fresh installs always work. So the fix isn't being more careful; it's building a process that forces the upgrade path to be tested.
Set Up Schema Export Before You Need It
The single most valuable thing you can do is export your schema to JSON and commit it to version control. Without exported schemas, AutoMigration can't work and MigrationTestHelper has nothing to build old databases from. If you're starting a project, do this on day one. If you're inheriting one without it, do it before the next schema change.
With the Room Gradle plugin, the setup is short:
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("androidx.room")
}
room {
// Each version gets its own JSON file, e.g. schemas/.../3.json
schemaDirectory("$projectDir/schemas")
}
dependencies {
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
androidTestImplementation("androidx.room:room-testing:2.6.1")
}Make sure exportSchema isn't set to false on your @Database annotation. Then treat those JSON files like source code: review them in pull requests. A diff in schemas/ is a loud signal that someone changed the database and needs to ship a migration.
Older setups
If a project still passes room.schemaLocation as a KSP or kapt argument instead of using the Gradle plugin, you'll also need to add the schemas folder to your androidTest assets so the migration test helper can find the JSON files.
AutoMigration vs Manual Migrations in Room
Since Room 2.4, you can declare AutoMigrations and let Room generate the SQL by diffing two exported schemas. For simple, additive changes this is genuinely great, and I default to it whenever it fits.
When AutoMigration is enough
Adding a new table, adding a nullable column, or adding a column with a default value are all handled automatically. Renames and deletions are ambiguous (did you rename body to content, or delete one and add another?), so Room asks you to clarify with an AutoMigrationSpec:
@Database(
entities = [NoteEntity::class, TagEntity::class],
version = 3,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = AppDatabase.Migration2To3::class)
]
)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
@RenameColumn(tableName = "notes", fromColumnName = "body", toColumnName = "content")
class Migration2To3 : AutoMigrationSpec
}When to write SQL by hand
AutoMigration only understands schema. The moment your change involves data transformation, you need a manual Migration. Examples: splitting one column into two, converting a string status into an integer enum, merging tables, or backfilling a new non-null column from existing data.
SQLite's limited ALTER TABLE support means many changes require the create-copy-drop-rename dance:
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. New table matching the updated NoteEntity exactly
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS notes_new (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_archived INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
""".trimIndent()
)
// 2. Copy and transform: old 'status' string becomes a boolean flag
db.execSQL(
"""
INSERT INTO notes_new (id, title, content, is_archived, updated_at)
SELECT id, title, content,
CASE WHEN status = 'archived' THEN 1 ELSE 0 END,
COALESCE(updated_at, created_at)
FROM notes
""".trimIndent()
)
// 3. Swap tables and recreate indices
db.execSQL("DROP TABLE notes")
db.execSQL("ALTER TABLE notes_new RENAME TO notes")
db.execSQL("CREATE INDEX IF NOT EXISTS index_notes_updated_at ON notes (updated_at)")
}
}My trick for getting the CREATE TABLE statement right: copy it from the exported JSON for the new version. Room writes the exact createSql it expects, including column order, constraints and defaults. Writing it from memory is how you end up with validation errors.
You can mix both approaches. Keep AutoMigrations for the simple steps and register manual ones with addMigrations() on the builder. Room chains them to get from any old version to the current one.
Testing Room Database Migrations Properly
This is the part most teams skip, and it's the part that actually prevents incidents. The room-testing artifact gives you MigrationTestHelper, which can create a database at any historical version from your exported schemas, run migrations, and validate the result against the expected schema.
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val testDb = "migration-test"
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrate3To4_preservesAndTransformsData() {
helper.createDatabase(testDb, 3).apply {
execSQL(
"INSERT INTO notes (id, title, content, status, created_at, updated_at) " +
"VALUES (1, 'Groceries', 'Milk', 'archived', 1000, NULL)"
)
close()
}
val db = helper.runMigrationsAndValidate(testDb, 4, true, MIGRATION_3_4)
db.query("SELECT is_archived, updated_at FROM notes WHERE id = 1").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
assertEquals(1000L, cursor.getLong(1)) // fell back to created_at
}
}
@Test
fun migrateAll_fromFirstVersionToLatest() {
helper.createDatabase(testDb, 1).close()
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
testDb
).addMigrations(*ALL_MIGRATIONS).build().apply {
openHelper.writableDatabase.close()
}
}
}Two kinds of tests matter here:
- Per-migration tests with real data. Insert rows at the old version, migrate, then assert on the data, not just the schema. Schema validation won't catch a
CASEstatement that maps values backwards. - An all-versions test. Start at version 1 and open the database with the production builder configuration. This catches gaps in the chain and also verifies that your AutoMigrations and manual migrations play nicely together.
Keep ALL_MIGRATIONS as a single array that both your production builder and your tests reference. If they diverge, your tests prove nothing.
CI tip
These are instrumented tests, so they need an emulator or a managed device. I'd rather run a small migration suite on every PR touching the schemas/ folder than discover a broken upgrade path from crash reports.
Destructive Fallback and Other Traps
Room offers fallbackToDestructiveMigration(), which drops and recreates the database whenever a migration path is missing. It makes the crash go away, which is exactly why it's dangerous. In client codebases I've seen it added as a quick fix during development and then shipped to production, where it silently wiped locally stored data on the next schema change.
Use with intent
Destructive fallback is only acceptable when the local database is a pure cache of server data that can be re-fetched. If users create anything offline, such as drafts, notes, unsynced changes or settings, destructive fallback means data loss.
Safer alternatives and related traps:
- Scope it narrowly.
fallbackToDestructiveMigrationFrom()lets you drop only from specific ancient versions you've deliberately decided not to support, while keeping proper migrations for everything else. - Downgrades. Users can end up on an older build, for example through internal testing tracks or sideloading. Decide explicitly whether to support that or use
fallbackToDestructiveMigrationOnDowngrade()for cache-only databases. - Never edit a shipped migration. Once a migration is in users' hands, it's history. If it was wrong, write a new migration that fixes the data forward.
- Watch long migrations. Copying large tables runs when the database first opens. Make sure your first database access happens off the main thread and that your UI can show a loading state.
- Prepopulated databases. If you ship a database with
createFromAsset(), its schema version must line up with your migrations too, or you'll hit the same validation errors.
On the architecture side, keeping Room behind a repository makes migrations less scary. When the rest of the app talks to domain models rather than entities, a table restructure stays contained in the data layer instead of rippling through ViewModels and UI.
One freelancing note: when I take over an existing Android app for a client, checking whether schemas are exported and whether migrations are tested is one of the first things I look at. It's a quick way to gauge how risky the next release is, and pointing out a gap like this early, before it bites, tends to build trust faster than any status update.
Key Takeaways
- Export your Room schema to JSON from day one and review changes to the
schemas/folder in every pull request. - Use AutoMigration for additive and rename changes; write manual migrations whenever data needs to be transformed, and copy
createSqlfrom the exported schema. - Test Room database migrations with
MigrationTestHelper, asserting on real data and running a full first-version-to-latest test. - Treat
fallbackToDestructiveMigration()as a deliberate product decision, never a quick fix, especially for offline-first apps. - Never modify a migration that has shipped; fix mistakes forward with a new version.