The Migration Problem
Which Room version are you migrating on?
| Room 2.x | Room 3.x | |
|---|---|---|
| Package | androidx.room | androidx.room3 |
| Migration function | migrate(db: SupportSQLiteDatabase) | suspend fun migrate(connection: SQLiteConnection) |
| Driver | Optional | "A SQLiteDriver is now required to build a RoomDatabase" |
Writing Your First Migration
migrate states: "This function is already called inside a transaction", one that may span every migration Room has to run (Android).
In Room 3 migrate is a suspend function, one more place where the coroutine rules apply. The guide adds one caution that is easy to break during a refactor: "use full queries instead of referencing constants that represent the queries". A migration describes history. If it references a constant, editing the constant rewrites the past.
Export the schema from the first version. Auto-migrations and migration tests both read the exported JSON, and the guide is explicit: "Store these files in your version control system". The Room Gradle plugin sets the location.// Step 1: update the entity
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: Long,
val name: String,
val email: String,
val avatarUrl: String? = null // NEW COLUMN (v2)
)
// Step 2: bump the database version
@Database(
entities = [User::class],
version = 2, // Was 1
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
// Step 3: define the migration (Room 3 signature)
val MIGRATION_1_2 = object : Migration(1, 2) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.executeSQL(
"ALTER TABLE users ADD COLUMN avatarUrl TEXT"
)
}
}
// Step 4: register it
val db = Room.databaseBuilder<AppDatabase>(context, "app.db")
.setDriver(AndroidSQLiteDriver())
.addMigrations(MIGRATION_1_2)
.build()
// build.gradle.kts: where the schema JSON is written
plugins {
id("androidx.room3")
}
room3 {
schemaDirectory("$projectDir/schemas")
}Complex Migrations: New Tables and Data Transforms
RENAME COLUMN in 3.25.0 (September 2018) and DROP COLUMN in 3.35.0 (March 2021). Android's own table shows what devices actually have (Android):
| Android API level | Platform SQLite | RENAME COLUMN | DROP COLUMN |
|---|---|---|---|
| 26 | 3.18 | No | No |
| 28 | 3.22 | No | No |
| 30 | 3.28 | Yes | No |
| 31 to 33 | 3.32 | Yes | No |
| 34 | 3.39, later 3.42 | Yes | Yes |
| 35 | 3.44 | Yes | Yes |
DROP COLUMN fails on part of your install base when it uses the platform SQLite. Even where it exists it is limited: it "only works if the column is not referenced by any other parts of the schema and is not a PRIMARY KEY and does not have a UNIQUE constraint".
The portable answer is the one SQLite documents for arbitrary schema changes: create the new table, copy the data, drop the old table, rename the new one. The order is not a matter of taste. SQLite warns that the reverse order, renaming the old table first, "might corrupt references to that table in triggers, views, and foreign key constraints". The same procedure tells you to recreate the indexes, triggers and views that belonged to the old table, which is the step most often forgotten.// Complex migration: split name into first_name + last_name
val MIGRATION_2_3 = object : Migration(2, 3) {
override suspend fun migrate(connection: SQLiteConnection) {
// 1. Create the new table under a temporary name
connection.executeSQL("""
CREATE TABLE users_new (
id INTEGER PRIMARY KEY NOT NULL,
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL,
avatarUrl TEXT
)
""")
// 2. Copy and transform the data
connection.executeSQL("""
INSERT INTO users_new (
id, first_name, last_name, email, avatarUrl
)
SELECT
id,
CASE
WHEN INSTR(name, ' ') > 0
THEN SUBSTR(name, 1, INSTR(name, ' ') - 1)
ELSE name
END,
CASE
WHEN INSTR(name, ' ') > 0
THEN SUBSTR(name, INSTR(name, ' ') + 1)
ELSE ''
END,
email,
avatarUrl
FROM users
""")
// 3. Drop the old table, 4. rename the new one. In this order.
connection.executeSQL("DROP TABLE users")
connection.executeSQL("ALTER TABLE users_new RENAME TO users")
// 5. Recreate the indexes, triggers and views the old
// table had. This one had none.
}
}Auto-Migrations: What Room Can Generate for You
@DeleteTable, @RenameTable, @DeleteColumn and @RenameColumn.
What an auto-migration cannot do is move data. The guide's own example is the limit: "if you decide to split the data in a table into two tables, Room can't determine how to perform this split." That is a manual migration. The two kinds mix freely, with one rule to remember: "If you define both an automated migration and a manual migration for the same version, then Room uses the manual migration."
Auto-migrations fail if exportSchema is false or if the higher version has not been compiled yet, because Room has nothing to compare.@Database(
entities = [User::class, Settings::class],
version = 5,
autoMigrations = [
AutoMigration(from = 3, to = 4),
AutoMigration(
from = 4,
to = 5,
spec = Migration4To5::class
),
],
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun settingsDao(): SettingsDao
}
// A spec is needed when Room cannot infer the change
@RenameColumn(
tableName = "users",
fromColumnName = "avatarUrl",
toColumnName = "profile_image_url"
)
@DeleteColumn(
tableName = "settings",
columnName = "legacy_theme"
)
class Migration4To5 : AutoMigrationSpecTesting Migrations
androidx.room3:room3-testing at 3.0.3, whose MigrationTestHelper creates a database at an old version from the exported schema, runs your migrations and validates the result.
Write two kinds of test:
1. One per migration, which inserts rows at the old version and checks them after the upgrade. The helper verifies the schema automatically. Whether the data was migrated properly is yours to assert.
2. One for the whole chain, from version 1 to the latest. The guide's reason: it "helps ensure that there's no discrepancy between a recently created database instance and an earlier instance that followed the defined migration paths".
The second test is the one that catches the user who skipped four releases. It matters most in an offline-first app, where the local database holds writes the server has not seen yet.@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val instrumentation =
InstrumentationRegistry.getInstrumentation()
@get:Rule
val helper = MigrationTestHelper(
instrumentation = instrumentation,
databaseClass = AppDatabase::class,
driver = AndroidSQLiteDriver(),
file = instrumentation.targetContext.getDatabasePath("migration-test"),
)
@Test
fun migrate1To2() = runTest {
// Create the database at version 1 and insert with SQL.
// DAOs expect the latest schema, so they cannot be used here.
val connection = helper.createDatabase(1)
connection.execSQL(
"INSERT INTO users (id, name, email) " +
"VALUES (1, 'Jane Doe', 'jane@example.com')"
)
connection.close()
// Re-open at version 2. The helper validates the schema.
val migrated = helper.runMigrationsAndValidate(
2, listOf(MIGRATION_1_2)
)
// The data is yours to check.
val survived = migrated
.prepare("SELECT COUNT(*) FROM users").use {
it.step()
it.getLong(0) == 1L
}
assertTrue("Row was lost in the migration", survived)
migrated.close()
}
@Test
fun migrateAll() = runTest {
// Earliest version of the database
helper.createDatabase(1).close()
// Latest version, with every manual migration registered.
// Room validates the schema when it opens.
val db = Room.databaseBuilder<AppDatabase>(
instrumentation.targetContext, "migration-test"
)
.setDriver(AndroidSQLiteDriver())
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
db.useReaderConnection { }
db.close()
}
}Destructive Fallback: When to Use It
fallbackToDestructiveMigration(dropAllTables = true) changes what happens when no migration path exists. Instead of throwing, Room recreates the tables. The guide's warning needs no paraphrase: "Room permanently deletes all data from the tables in the user's database when it attempts to perform a migration and there's no defined migration path."
That is acceptable for a cache that can be fetched again. It is not acceptable for anything the user created on the device. If you need an escape hatch, use the narrower forms:
- fallbackToDestructiveMigrationFrom(dropAllTables, vararg startVersions) limits it to specific old versions you have decided to abandon.
- fallbackToDestructiveMigrationOnDowngrade(dropAllTables) limits it to a downgrade from a higher version to a lower one.
On Room 2.x the same functions exist, and the no-argument overloads are deprecated in favour of the ones that take dropAllTables (Android). Small key-value settings do not belong in Room at all, which removes them from the migration problem: see the DataStore guide.Key Takeaways
- 1Room 3.0 changed the migration signature to suspend fun migrate(connection: SQLiteConnection). The SQL and the concepts are unchanged.
- 2Export schemas from version 1 and commit the JSON. Auto-migrations and migration tests both depend on it.
- 3Auto-migrations can add, rename and delete tables and columns. They cannot move data between them.
- 4DROP COLUMN needs SQLite 3.35.0, which the platform only ships from API 34. Below that, create, copy, drop and rename.
- 5Test every migration and the whole chain from version 1 with MigrationTestHelper.
- 6Destructive fallback permanently deletes user data. Keep it for caches.
Frequently Asked
What happens if I forget to add a migration?
Room throws an IllegalStateException when it opens the database, because it cannot find a path from the version on the device to the current one. If you enabled destructive fallback it deletes the data in every table instead. Neither is recoverable after release, which is why the full-chain migration test exists.
How do I test Room migrations?
Add the room3-testing artifact and use MigrationTestHelper. Create the database at the old version from the exported schema, insert rows with SQL, run the migration, then query to confirm the data survived. The helper validates the schema for you. Add one more test that migrates from version 1 to the latest.
Can a Room auto-migration delete or rename a column?
Yes. Room raises a compile-time error for an ambiguous change, and you resolve it with an AutoMigrationSpec class annotated with @DeleteColumn, @RenameColumn, @DeleteTable or @RenameTable. What an auto-migration cannot do is transform or move data, such as splitting one table into two. Write a manual migration for that.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.