Room Database Migrations: A Survival Guide

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

Introduction

Database schema changes are inevitable in production apps. Room provides a migration system that preserves user data while evolving your schema. This guide covers migration strategies, testing, and handling edge cases.

Understanding Room Migrations

Migrations are SQL scripts that transform schema from version N to N+1. Room executes migrations sequentially. Each migration must handle both schema changes and data transformation.

Writing Migration Scripts

Use ALTER TABLE for adding columns. Use CREATE TABLE and INSERT for complex transformations. Handle null constraints by adding columns as nullable, backfilling data, then making non-null.

Testing Migrations

Use SupportSQLiteOpenHelper to create database at old version. Run migration. Verify schema and data with queries. Automate migration tests in CI to catch breaking changes before release.

Destructive Migration Fallback

Use fallbackToDestructiveMigration() only for caches or non-essential data. For user data, always provide proper migrations. Consider fallbackToDestructiveMigrationOnDowngrade() for version rollback scenarios.

Frequently Asked Questions

What if I forget to add a migration?

Room will crash with 'Room can't migrate database' error. Users lose local data. Always test migrations before release. Use autoMigrations for simple schema changes in Room 2.4+.

How do I test migrations?

Create database at old version, run migration, verify schema and data. Use Room's MigrationTestHelper or manually copy database files. Test all migration paths, not just consecutive versions.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:38:00
UP00:01
MOD · ARTICLE · BEST PRACTICESS/N · AX-ROOM-DPUBLISHED
Best PracticesFeb 20, 20267 MIN

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 · Founder & Principal Consultant, Strategia-X

The Migration Problem

Every Android app that persists data locally will eventually need to change its database schema. New features require new tables, evolving requirements add columns, and performance improvements restructure indexes. Without proper migration handling, these changes crash the app on update and destroy user data. Room provides a migration framework that lets you define upgrade paths between schema versions. When a user opens the app after an update, Room detects the version mismatch and executes the appropriate migration SQL. If no migration path exists, Room either crashes (the safe default) or wipes the database (destructive fallback).

Writing Your First Migration

A Room migration defines the SQL statements needed to transform the database from version N to version N+1. The migration runs inside a transaction, so if any statement fails, the entire migration rolls back and the database remains unchanged.
kotlin
// Step 1: Update your 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  // Always export for testing
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

// Step 3: Define the migration
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE users ADD COLUMN avatarUrl TEXT"
        )
    }
}

// Step 4: Register the migration
val db = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "app.db"
)
    .addMigrations(MIGRATION_1_2)
    .build()

Complex Migrations: New Tables and Data Transforms

Adding a column is straightforward, but real-world migrations are often more complex. You might need to create new tables, copy data with transformations, or restructure relationships. SQLite's ALTER TABLE is limited -- you cannot rename or remove columns in older versions. The workaround is the "create new table, copy data, drop old table, rename" pattern.
kotlin
// Complex migration: split full_name into first_name + last_name
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // Create new table with updated schema
        db.execSQL("""
            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
            )
        """)

        // Copy and transform data
        db.execSQL("""
            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
        """)

        // Replace old table
        db.execSQL("DROP TABLE users")
        db.execSQL("ALTER TABLE users_new RENAME TO users")
    }
}

Auto-Migrations in Room 2.4+

Room 2.4 introduced auto-migrations that generate migration SQL from schema differences. For simple changes like adding a column or a new table, auto-migrations eliminate manual SQL entirely. Room compares exported schema files (JSON) between versions and generates the required DDL. Auto-migrations handle: adding columns, adding tables, adding indexes, and simple column renames (with an annotation). They do not handle: data transformations, column removal, or type changes. For those, you still need manual migrations.
kotlin
@Database(
    entities = [User::class, Settings::class],
    version = 4,
    autoMigrations = [
        AutoMigration(from = 2, to = 3),
        AutoMigration(
            from = 3,
            to = 4,
            spec = Migration3to4::class
        ),
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun settingsDao(): SettingsDao
}

// Spec needed when Room can't infer the change
@RenameColumn(
    tableName = "users",
    fromColumnName = "avatar_url",
    toColumnName = "profile_image_url"
)
class Migration3to4 : AutoMigrationSpec

Testing Migrations

Untested migrations are a ticking time bomb. Room provides a MigrationTestHelper that creates a database at an old version, runs your migration, and verifies the new schema matches expectations. Always test migrations before releasing. The test helper uses exported schema files (the JSON files Room generates when `exportSchema = true`). Keep these in your repository and never delete old versions -- they are needed for migration testing.
kotlin
@RunWith(AndroidJUnit4::class)
class MigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrate_1_to_2() {
        // Create database at version 1
        val db = helper.createDatabase("test-db", 1).apply {
            execSQL("""
                INSERT INTO users (id, name, email)
                VALUES (1, 'Jane Doe', '[email protected]')
            """)
            close()
        }

        // Run migration and validate
        val migratedDb = helper.runMigrationsAndValidate(
            "test-db", 2, true, MIGRATION_1_2
        )

        // Verify data survived
        val cursor = migratedDb.query(
            "SELECT avatarUrl FROM users WHERE id = 1"
        )
        assertTrue(cursor.moveToFirst())
        assertNull(cursor.getString(0)) // Default null
        cursor.close()
    }

    @Test
    fun migrate_all_versions() {
        // Test the full migration chain
        helper.createDatabase("test-db", 1).close()
        helper.runMigrationsAndValidate(
            "test-db",
            4,  // Latest version
            true,
            MIGRATION_1_2,
            MIGRATION_2_3
            // Auto-migrations handled by Room
        )
    }
}

Destructive Fallback: When to Use It

`fallbackToDestructiveMigration()` tells Room to wipe the database and recreate it from scratch when no migration path exists. This sounds convenient but should only be used when data loss is acceptable -- typically for caches or data that syncs from a server. For user-generated data that exists only on the device, destructive fallback is never acceptable. Users who lose their data after an app update will leave negative reviews and uninstall. Write proper migrations for every schema change that touches user data.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Always set exportSchema = true for migration testing.
  2. 2Use auto-migrations for simple changes; manual migrations for data transforms.
  3. 3Test every migration with MigrationTestHelper before releasing.
  4. 4Never use destructive fallback for user-generated data.
  5. 5Keep all exported schema JSON files in version control.
  6. 6The create-copy-drop-rename pattern handles complex column changes in SQLite.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

What if I forget to add a migration?

Room will crash with 'Room can't migrate database' error. Users lose local data. Always test migrations before release. Use autoMigrations for simple schema changes in Room 2.4+.

How do I test migrations?

Create database at old version, run migration, verify schema and data. Use Room's MigrationTestHelper or manually copy database files. Test all migration paths, not just consecutive versions.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

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.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA