Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 032 / 100

Room migrations: a Android & Kotlin guide for developers

Change your schema without losing data.

Expona Admin8 min read

Introduction

Database schema changes are inevitable as your Android app evolves. Users don't want to reinstall your app and lose their data just because you added a new column or renamed a table. Room migrations: a Android & Kotlin guide for developers covers the tools and patterns that let you transform your database schema while preserving user data seamlessly.

Room, Google's abstraction layer over SQLite, provides two migration paths: automated migrations for straightforward changes and manual migrations for complex transformations. Understanding when and how to use each approach is critical for shipping reliable updates without data loss or crashes in production.

Understanding Migration Basics

A migration in Room is a set of SQL operations that transforms your database from one schema version to another. Every time you change your @Entity definition—adding fields, removing them, modifying constraints—you must provide a migration path that Room can execute when users update your app.

Without migrations, Room will throw an exception when it detects a schema mismatch. The app will crash on launch, and users will be forced to uninstall and reinstall, losing all their local data. That's the scenario we're trying to prevent.

Room compares the current database schema against your entity definitions and determines whether a migration is needed. If the versions don't match and no migration path exists, the app fails. Our job is to define that path clearly.

Increment the Version

Every Room database has a version number, defined in your @Database annotation. When you make schema changes, you increment this number and provide a corresponding migration.

kotlin
@Database(
    entities = [User::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

When you need to modify the schema, increase the version number:

kotlin
@Database(
    entities = [User::class],
    version = 2,  // Incremented from 1
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

The exportSchema = true parameter tells Room to save JSON snapshots of your schema in the schemas folder. This is invaluable for version control and debugging. Keep these files in your repository; they serve as documentation of your schema evolution.

Version increments must be sequential and monotonic. You cannot skip versions or go backwards. If your current app is at version 5 and you want to deploy version 6, your migration must accept database version 5 as input and produce version 6 as output.

AutoMigration for Simple Changes

For straightforward modifications—adding a nullable column, removing a field, renaming a column—Room can generate the migration SQL automatically. You declare the intent, and Room handles the SQL.

Suppose your User entity originally looked like this:

kotlin
@Entity
data class User(
    @PrimaryKey val id: Long,
    val name: String,
    val email: String
)

Now you want to add a phone field that is nullable:

kotlin
@Entity
data class User(
    @PrimaryKey val id: Long,
    val name: String,
    val email: String,
    val phone: String? = null  // New nullable field
)

In your database class, add the @AutoMigration annotation:

kotlin
@Database(
    entities = [User::class],
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

Room will automatically generate the ALTER TABLE statement to add the new column. No custom migration code required.

AutoMigrations work for:

  • Adding a nullable column
  • Removing a column
  • Renaming a column (with @RenameColumn)
  • Renaming a table (with @RenameTable)
  • Changing a column's default value

For renaming operations, you must provide the spec:

kotlin
@Database(
    entities = [User::class],
    version = 2,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = Migration1to2::class
        )
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

@RenameColumn(
    tableName = "user",
    fromColumnName = "old_name",
    toColumnName = "new_name"
)
class Migration1to2 : AutoMigrationSpec

AutoMigrations are fast to implement and less error-prone. Use them whenever Room supports the schema change. Reserve manual migrations for complex transformations.

Manual Migration for Complex Changes

Some schema changes are too intricate for AutoMigration. Imagine you want to split a fullName column into firstName and lastName, normalizing data in the process. That requires custom SQL logic.

Here's a concrete scenario: you have a Product entity with a price column stored as an integer (cents). You want to split it into basePriceCents and taxCents, calculating tax from an external rule.

Define a Migration object:

kotlin
val migration1to2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // Add new columns to the product table
        db.execSQL("ALTER TABLE product ADD COLUMN base_price_cents INTEGER NOT NULL DEFAULT 0")
        db.execSQL("ALTER TABLE product ADD COLUMN tax_cents INTEGER NOT NULL DEFAULT 0")
        
        // Populate the new columns based on the old price
        db.execSQL("""
            UPDATE product 
            SET base_price_cents = CAST(price * 0.9 AS INTEGER),
                tax_cents = CAST(price * 0.1 AS INTEGER)
        """)
        
        // Remove the old column (optional, but keeps schema clean)
        // Note: SQLite doesn't support DROP COLUMN directly in older versions,
        // so we'd typically just leave it or use a more complex approach.
    }
}

Register the migration when building your database:

kotlin
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(migration1to2)
    .build()

Manual migrations give you full control. You can:

  • Transform data using SQL expressions
  • Create temporary tables and transfer data
  • Call Kotlin code to compute values (with constraints)
  • Handle multi-step schema changes

However, each manual migration is a small program with SQL logic and edge cases. Test them thoroughly.

Test Migrations with MigrationTestHelper

Room provides MigrationTestHelper to test migrations in isolation before shipping to users. You create a database at the old version, trigger the migration, and verify the result matches expectations.

Set up the dependency in your test build.gradle:

kotlin
testImplementation("androidx.room:room-testing:2.6.0")

Write a migration test:

kotlin
@RunWith(AndroidJUnit4::class)
class UserMigrationTest {
    @get:TemporaryFolder
    val testFolder = TemporaryFolder()

    private lateinit var helper: MigrationTestHelper

    @Before
    fun setUp() {
        helper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java,
            emptyList(),
            testFolder
        )
    }

    @Test
    fun migrate1to2_addsPhoneColumn() {
        // Create a version 1 database with test data
        val db1 = helper.createDatabase("test.db", 1)
        db1.execSQL("""
            INSERT INTO user (id, name, email)
            VALUES (1, 'Alice', 'alice@example.com')
        """)
        db1.close()

        // Run the migration to version 2
        val db2 = helper.runMigrationsAndValidate(
            "test.db",
            2,
            true,
            migration1to2
        )

        // Verify the schema and data
        val cursor = db2.query("SELECT * FROM user WHERE id = 1")
        cursor.moveToFirst()
        assert(cursor.getColumnIndex("phone") >= 0)
        assert(cursor.getString(cursor.getColumnIndex("name")) == "Alice")
        cursor.close()
        db2.close()
    }
}

The helper.runMigrationsAndValidate() method:

  1. Loads the old database
  2. Executes your migration
  3. Validates the resulting schema against your current entities
  4. Returns the migrated database for inspection

Query the migrated database to verify data integrity. Check counts, values, and relationships. Also test edge cases: what if a column was null? What if there were duplicate keys?

For complex migrations spanning multiple versions, test each step:

kotlin
@Test
fun migrate1to3_throughVersion2() {
    val db1 = helper.createDatabase("test.db", 1)
    // Insert test data
    db1.close()

    val db2 = helper.runMigrationsAndValidate("test.db", 2, true, migration1to2)
    db2.close()

    val db3 = helper.runMigrationsAndValidate("test.db", 3, true, migration2to3)
    // Verify final state
    db3.close()
}

Run these tests on a real device or emulator before every release. Migrations failing in production are catastrophic; testing catches bugs early.

Common Mistakes

Forgetting to increment the version: If you change your schema but don't update the version parameter, Room won't detect the change and crashes will happen. Always increment before deploying schema changes.

Breaking AutoMigration with unsupported operations: Adding a non-nullable column without a default value breaks AutoMigration. Room can't populate existing rows. Add a default value or make it nullable.

Chaining migrations incorrectly: If you have versions 1, 2, and 3, ensure your migrations form a continuous chain: 1→2 and 2→3. A migration from 1→3 that skips version 2 will fail if a user is on version 2.

Modifying migrations after release: Never change a migration after users have run it. Instead, create a new migration that fixes the issue. Room executes migrations only once per database version; changing them creates inconsistent databases in the field.

Not testing with real data: Test migrations with data shapes that exist in production: large result sets, null values, edge case IDs, duplicates. Synthetic test data often misses problems.

Key Takeaways

  • Increment your database version whenever you change schema; Room won't recognize schema mismatches without a version bump and corresponding migration.
  • Use AutoMigration for simple, supported changes like adding nullable columns or renaming fields; it reduces boilerplate and errors.
  • Write manual migrations for complex transformations; they give you full SQL control but require careful implementation and thorough testing.
  • Always test migrations with MigrationTestHelper before shipping; catching bugs in the lab prevents crashes in the field.
  • Keep schema JSON exports in version control; they document your schema evolution and aid debugging.
  • Never modify a migration after releasing it; create a new migration instead to fix issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles