Kahibaro
Discord Login Register

15.4 Database Migrations

Why Database Migrations Matter

As your app grows, you will often need to change the database structure. You might add a new column, rename a table, or introduce a new entity. These changes affect the underlying SQLite schema. If you do not handle these changes properly, your app can crash on update or silently lose user data.

A database migration is the explicit set of instructions that tells Room how to move from one version of your database schema to another, while preserving existing data. You define these instructions once, and Room executes them automatically when the app starts with a newer schema version.

Room requires that every schema change is associated with a version number, and that there is a valid migration path from the installed version to the new one. This lets your users update the app without needing to uninstall and reinstall it.

Important: Any schema change in a Room database must be accompanied by:

  1. An incremented database version.
  2. Matching migration logic, or an explicit decision to destroy and recreate the database.
    Failure to do so can cause crashes or data loss.

Versioning the Room Database

When you define your Room database, you specify a version in the @Database annotation. This version represents the current schema layout of all entities in that database.

For example, an initial database might look like this:

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

If later you modify an entity, add a new one, or change a relationship, you must increase this version. For example, after adding a new Book entity or altering User, you might change the version to 2:

@Database(
    entities = [User::class, Book::class],
    version = 2
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun bookDao(): BookDao
}

The version number tells Room that a migration from 1 to 2 is required. Room compares the stored version in the SQLite database file with the version in the @Database annotation at runtime. If they differ, Room attempts to run migrations that you have registered.

Defining Migration Objects

A migration in Room is represented by a Migration object. It specifies a starting version, an ending version, and an override fun migrate method that receives a SupportSQLiteDatabase. Inside this method, you write raw SQL statements to update the schema.

A simple migration from version 1 to 2 that adds a new column might look like this:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0"
        )
    }
}

The SupportSQLiteDatabase argument allows you to execute SQL such as ALTER TABLE, CREATE TABLE, CREATE INDEX, and UPDATE. You do not use DAOs here. You work directly with SQL because DAOs are generated from the new schema and are not designed for partial migrations.

Each migration object always covers one specific path, from one version to another. Room can chain multiple migrations together to move across more than one version, but each piece of logic is linear from one integer to another.

Rule: A Migration must:

  1. Correctly transform the old schema to match the new one.
  2. Preserve data whenever possible.
  3. Avoid using DAOs, and instead use SupportSQLiteDatabase and SQL.

Registering Migrations with the Database Builder

Defining a Migration object is not enough. You must also register it with the Room database builder when you create your database instance. If Room cannot find a proper migration path between the on disk version and the current version, initialization will fail unless you explicitly tell Room to destroy the existing database.

Here is how you attach migrations:

val db = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "my_database"
)
.addMigrations(MIGRATION_1_2)
.build()

If your database has several migrations, you can pass multiple arguments:

.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

Room will choose the correct sequence of migrations based on the current stored version and the target version.

If any required migration is missing, Room throws an exception at runtime when it tries to open the database, unless you have configured it to fall back to destructive migration.

Common Migration Scenarios

Most schema changes fall into a few typical categories. Each category requires specific SQL operations. Room does not auto generate migrations, so you must design them carefully.

Adding a New Column

The most common change is adding a new field to an existing entity, which means adding a new column to a table. For example, you add an age property to the User entity. In SQL, this is usually an ALTER TABLE operation.

Suppose your original User table is:

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

You update it to:

@Entity
data class User(
    @PrimaryKey val id: Long,
    val name: String,
    val age: Int
)

You then define a migration:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0"
        )
    }
}

Note that the SQL must match the new schema. If age is non nullable in Kotlin, the column must have NOT NULL and a default value, or you must insert initial values manually.

Adding a New Table

If you add a new entity, Room will expect the database schema to contain a new table. The migration then creates that table using CREATE TABLE. For instance, if you introduce a Book entity, you might write:

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            """
            CREATE TABLE IF NOT EXISTS Book(
                id INTEGER PRIMARY KEY NOT NULL,
                title TEXT NOT NULL,
                userId INTEGER NOT NULL,
                FOREIGN KEY(userId) REFERENCES User(id) ON DELETE CASCADE
            )
            """.trimIndent()
        )
    }
}

The migration now aligns the underlying SQLite schema with your list of entities in @Database.

Renaming a Column or Table

SQLite does not fully support renaming columns in a simple way across all Android versions, so you often need to recreate the table. The general strategy is:

Create a new table with the desired schema.
Copy the data from the old table to the new one.
Drop the old table.
Optionally rename the new table to the old name.

For example, you change the User entity property name to fullName. On the SQLite side, you must recreate the table and map data:

val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            """
            CREATE TABLE User_new(
                id INTEGER PRIMARY KEY NOT NULL,
                fullName TEXT NOT NULL
            )
            """.trimIndent()
        )
        database.execSQL(
            """
            INSERT INTO User_new (id, fullName)
            SELECT id, name FROM User
            """.trimIndent()
        )
        database.execSQL("DROP TABLE User")
        database.execSQL("ALTER TABLE User_new RENAME TO User")
    }
}

This pattern is very common for any change that SQLite cannot handle directly with a simple ALTER TABLE. It also applies when you want to remove columns or substantially modify constraints.

Removing Columns or Changing Constraints

Removing a column or changing constraints, such as primary keys or foreign keys, typically also requires the create copy drop pattern. You define a new table without the old column or with the new constraints, copy over only the columns that still exist, then replace the original table. The SQL pattern is the same as renaming, but your INSERT selects fewer or differently named columns.

Whenever you recreate tables, carefully check that foreign keys, indexes, and constraints are all recreated correctly.

Handling Multiple Migration Steps

If your app has been in production for a long time, you might have several schema versions. A user might update the app from version 1 directly to version 4. Room handles this by chaining migrations.

Imagine the following migrations:

From 1 to 2: MIGRATION_1_2.
From 2 to 3: MIGRATION_2_3.
From 3 to 4: MIGRATION_3_4.

When a user with version 1 of the database installs an app that expects version 4, Room calculates a path from 1 to 4 using the available migrations. It applies MIGRATION_1_2, then MIGRATION_2_3, then MIGRATION_3_4, in order.

For this to work, all intermediate migrations must be registered with addMigrations. You do not need a direct migration from 1 to 4 as long as the chain covers every step without gaps.

Rule: For every possible installed database version, there must be a complete chain of migrations to the current version, or you must explicitly choose a destructive fallback strategy.

Destructive Migration and When to Use It

Sometimes you do not want to write migrations, especially for development builds or for databases that contain only cached or temporary data. Room allows you to configure destructive migration, which discards the existing database and recreates it from the current schema.

A typical configuration looks like this:

val db = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "cache_database"
)
.fallbackToDestructiveMigration()
.build()

With this option, if Room cannot find a migration between the stored version and the current version, it deletes the database file and creates a new one. All data in that database is lost.

This can also be restricted to specific cases, such as migration from higher to lower versions or for particular schema changes. For example, if you only expect upgrades but want to ignore any downgrade path:

.fallbackToDestructiveMigrationOnDowngrade()

Destructive migration can be very useful during development when schemas change often, or for data that can safely be regenerated. For user generated data, you usually should not rely on destructive migration, because it will erase user data during an update.

Testing and Verifying Migrations

Migrations are fragile, because you are writing raw SQL and handling data manually. Small mistakes can cause runtime crashes or subtle data corruption that you do not notice immediately. It is important to test migrations carefully before releasing a new version.

Room provides testing support that allows you to:

Generate a schema snapshot as JSON for each version.
Start from an older schema, then run your migrations and verify that the resulting schema matches expectations.
Insert test data into the old version, then check that it is correctly preserved and transformed in the new version.

In unit tests, you usually use MigrationTestHelper to simulate opening a database at an older version, then apply migrations and inspect the result.

Even without automated tests, you can manually verify migrations by:

Running your app with an older version on an emulator.
Populating some data.
Updating the app with the new version and migrations.
Checking for crashes and verifying that data still exists and looks correct.

Rule: Never release a schema change without testing the corresponding migrations on real or test data. Always verify both structure and data.

Using AutoMigration for Simple Changes

Room supports automatic migrations in some scenarios, where it can generate the SQL for you, but this is only available when using features from newer versions of Room. AutoMigration works when the changes between versions are simple and can be safely inferred, such as some column additions or index changes.

To use auto migration, you annotate your @Database with an autoMigrations property and describe which versions can be migrated automatically. For example:

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

In this setup, Room internally generates the migration logic between version 1 and 2. This can simplify maintenance for straightforward changes. Complex changes, such as table recreation or data transformations, still require manual Migration implementations.

You should always consult the Room documentation for the exact rules of what auto migration can and cannot handle, and adopt it only when it clearly matches your change.

Practical Strategies for Safe Schema Evolution

When working with Room migrations over time, it helps to follow some consistent practices that reduce risk and make maintenance easier.

Plan schema changes in small steps, and avoid large, complex migrations whenever possible. It is easier to debug a simple column addition than a complete schema redesign.

Keep a changelog of database versions. Track which app versions introduced each database version, and what the migration did.

Include default values for new non nullable columns so that existing rows can be updated safely during migration.

Avoid frequent destructive migrations in production. They may appear convenient but harm user trust if data disappears without warning.

Use debug builds with destructive migration during rapid development, but switch to explicit migrations before release.

Keep your SQL in migrations readable and well formatted, since you may need to revisit it long after the change.

By handling database migrations with care, you ensure that your Room based storage remains reliable as your application evolves, and that users can upgrade without data loss.

Views: 1

Comments

Please login to add a comment.

Don't have an account? Register now!