---
title: "Room Database Migrations: A Survival Guide"
description: "Handle schema changes gracefully with Room's migration system, destructive fallbacks, and automated migration testing strategies."
url: https://android-x.com/blog/room-database-migrations
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-02-20
updated: 2026-10-11
category: "Best Practices"
reading_time: "9 min read"
---

# Room Database Migrations: A Survival Guide

> Handle schema changes gracefully with Room's migration system, destructive fallbacks, and automated migration testing strategies.

By Rocky Elsalaymeh. Published 2026-02-20, updated 2026-10-11. Canonical page: https://android-x.com/blog/room-database-migrations

## The Migration Problem

Every app that stores data on the device will change its schema. A feature needs a table, a requirement adds a column, a slow query needs an index. The user's database was created by an older build, and something has to carry it forward.

[Room](https://developer.android.com/training/data-storage/room) does that with migrations, and its default when one is missing is to stop: "If Room can't find a migration path to upgrade an existing database on a device to the current version, an IllegalStateException occurs" ([Android](https://developer.android.com/training/data-storage/room/migrating-db-versions)). The alternative you can opt into is worse for anything the user typed, and it gets its own section at the end.

This guide was rewritten in October 2026 because the ground moved. Room 3.0.0 shipped on 1 July 2026, the official migration guide now teaches the Room 3 API, and the samples that used to be here would not compile against it.

## Which Room version are you migrating on?

Two lines are current. Room 2.8.5 is the latest 2.x release and Room 3.0.3 is the latest 3.x, both dated 9 September 2026 ([Room 2 releases](https://developer.android.com/jetpack/androidx/releases/room), [Room 3 releases](https://developer.android.com/jetpack/androidx/releases/room3)). The Room 3 notes list the breaking differences: "New package, androidx.room3. SupportSQLite APIs are no longer supported, unless you are using the androidx.room3:room3-sqlite-wrapper. All database operations are now Coroutine APIs based. Kotlin code generation only. Kotlin Symbol Processing (KSP) is required."

For migrations that comes down to this:

| | 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" |

The concepts are unchanged: versions, migration objects, exported schemas, auto-migrations. "The core annotation APIs are kept the same", in the release notes' words. The samples below use Room 3. On 2.x the SQL is identical and only the function signature differs.

## Writing Your First Migration

A [migration](https://developer.android.com/training/data-storage/room/migrating-db-versions) holds the SQL that takes the database from one version to the next. You do not open a transaction yourself. The reference for `migrate` states: "This function is already called inside a transaction", one that may span every migration Room has to run ([Android](https://developer.android.com/reference/kotlin/androidx/room3/migration/Migration)).

In Room 3 `migrate` is a suspend function, one more place where the [coroutine rules](https://android-x.com/blog/kotlin-coroutines-deep-dive) 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.

```kotlin
// 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

Adding a column is the easy case. Renaming or removing one depends on which SQLite the device runs, and that depends on the Android version. [SQLite](https://www.sqlite.org/lang_altertable.html) added `RENAME COLUMN` in [3.25.0](https://www.sqlite.org/releaselog/3_25_0.html) (September 2018) and `DROP COLUMN` in [3.35.0](https://www.sqlite.org/releaselog/3_35_0.html) (March 2021). Android's own table shows what devices actually have ([Android](https://developer.android.com/reference/android/database/sqlite/package-summary)):

| 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 |

With a minSdk below 34, a migration that relies on `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.

```kotlin
// 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

Room has generated migrations since 2.4.0, released on 15 December 2021: "Room now offers an API for automatically generating migrations as long as schemas are exported" ([release notes](https://developer.android.com/jetpack/androidx/releases/room)). Declare the version pair and Room compares the two exported schema files.

An earlier version of this article said auto-migrations cannot remove a column. They can. When a change is ambiguous Room "throws a compile-time error and you must provide an AutoMigrationSpec implementation", and the guide lists the four annotations that resolve it: `@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.

```kotlin
@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 : AutoMigrationSpec
```

## Testing Migrations

"Migrations are often complex, and an incorrectly defined migration can cause your app to crash," the guide says, and a migration runs exactly once per device, on data you cannot see. Room ships a testing artifact for this, `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](https://android-x.com/blog/offline-first-android-architecture), where the local database holds writes the server has not seen yet.

```kotlin
@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](https://developer.android.com/reference/androidx/room/RoomDatabase.Builder)). Small key-value settings do not belong in Room at all, which removes them from the migration problem: see the [DataStore guide](https://android-x.com/blog/jetpack-datastore-guide).

## Key takeaways

- Room 3.0 changed the migration signature to suspend fun migrate(connection: SQLiteConnection). The SQL and the concepts are unchanged.
- Export schemas from version 1 and commit the JSON. Auto-migrations and migration tests both depend on it.
- Auto-migrations can add, rename and delete tables and columns. They cannot move data between them.
- DROP COLUMN needs SQLite 3.35.0, which the platform only ships from API 34. Below that, create, copy, drop and rename.
- Test every migration and the whole chain from version 1 with MigrationTestHelper.
- Destructive fallback permanently deletes user data. Keep it for caches.

## Frequently asked questions

### 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.

## Related articles

- [Kotlin Coroutines Deep Dive: Structured Concurrency in Practice](https://android-x.com/blog/kotlin-coroutines-deep-dive.md)
- [Offline-First Android Architecture with Room and WorkManager](https://android-x.com/blog/offline-first-android-architecture.md)
- [Jetpack DataStore: The Modern Replacement for SharedPreferences](https://android-x.com/blog/jetpack-datastore-guide.md)
