How to Handle Room Database Migrations Like a Pro: Avoiding Data Loss
Room is one of the most popular persistence libraries for Android developers. It abstracts away a lot of boilerplate and gives us an easy way to work with SQLite. But when your app evolves and your database schema changes, you need to handle Room Database Migrations properly — or you risk losing your users’ data.
This guide shows you how to manage Room Database Migrations like a pro. We’ll keep it simple, clear, and practical, and explain everything you need to know to avoid headaches and, more importantly, data loss.
Why Room Database Migrations Matter
When you update your database schema (say, add a new column or table), Room requires a migration strategy. If you skip this, the app may crash or wipe the existing data.
You don’t want this:
java.lang.IllegalStateException: A migration from 1 to 2 was required but not found.That error is telling you that Room has no idea how to safely move from your old schema (version 1) to the new one (version 2). That’s where migrations come in.
Plan Your Schema Changes
Before you touch a line of code, plan your changes. Think about:
- What tables or columns are being added, removed, or modified?
- How will existing data map to the new structure?
- Are there any relationships or foreign keys to update?
Planning ahead reduces surprises and makes your migrations safer.
How to Handle Migrations in Room
Let’s walk through a clean and simple approach to Room Database Migrations.
1. Set Up Room With Versioning
Start by defining your Room database with a version number:
@Database(entities = [User::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}When you change your schema (say, add a column), increment the version.
2. Define a Migration Object
Create a Migration object that tells Room how to go from the old version to the new one:
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")
}
}This runs a raw SQL command. In this case, we’re adding a new column age to the User table.
3. Add the Migration When Building the Database
Now pass the migration object when building your Room database:
Room.databaseBuilder(context, AppDatabase::class.java, "app-db")
.addMigrations(MIGRATION_1_2)
.build()Without this step, Room won’t know how to migrate and will crash or wipe data.
Pro Tips to Avoid Data Loss
Here are some best practices to help you stay safe during Room Database Migrations:
1. Never Use fallbackToDestructiveMigration() in Production
Room.databaseBuilder(context, AppDatabase::class.java, "app-db")
.fallbackToDestructiveMigration()This will destroy the old database and create a new one — which means all data is lost. Great for prototyping. Terrible for real users.
2. Use Migration Testing
Use Room’s migration testing support to ensure your migrations work.
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val TEST_DB = "migration-test"
@Test
fun migrate1To2() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
// Create database with version 1 schema
helper.createDatabase(TEST_DB, 1).apply {
close()
}
// Run migration and validate schema
Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
TEST_DB
).addMigrations(MIGRATION_1_2).build().apply {
openHelper.writableDatabase.close()
}
}
}This ensures your migration script actually works before hitting users.
3. Keep Migrations in Version Order
Always write migration paths sequentially: 1 to 2, then 2 to 3, and so on. Room will chain them automatically.
4. Document Schema Changes
Leave comments in code or maintain a changelog. Know why a change was made and when.
Advanced: Manual Data Transformation
Sometimes you need more than just SQL.
Example: If you’re renaming a column, you can’t just ALTER TABLE. SQLite doesn’t support renaming columns directly.
Workaround:
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE User_new (id INTEGER PRIMARY KEY NOT NULL, name TEXT, age INTEGER NOT NULL)")
database.execSQL("INSERT INTO User_new (id, name, age) SELECT id, name, age FROM User")
database.execSQL("DROP TABLE User")
database.execSQL("ALTER TABLE User_new RENAME TO User")
}
}This way, you restructure the table safely and migrate data manually.
Handling More Complex Room Database Migrations
For advanced scenarios — like splitting tables, changing foreign keys, or migrating large datasets — break the migration into steps:
- Create new tables if needed
- Copy data from old to new tables
- Drop or rename old tables
- Update relationships and foreign keys
Always test each step individually to ensure nothing is lost or corrupted.
Auto Migration: When to Use It
Room supports auto migration for simple schema changes, like adding a column. Just declare the migration in your database class:
@Database(
version = 2,
entities = [User::class],
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()Auto migration is fast and easy, but for anything more complex, manual migrations are safer and more flexible
Conclusion
Room Database Migrations are powerful — but only if you use them correctly. Here’s your checklist:
- Always bump the version when schema changes.
- Write a proper
Migrationobject. - Add migrations to
databaseBuilder(). - Never use
fallbackToDestructiveMigration()in production. - Test migrations before deploying.
- Document and keep migration paths clear.
With these practices, you can handle Room Database Migrations like a pro and protect your users’ data every step of the way.














