Skip to content
All posts
August 15, 20268 min read

Room Database Migration Strategies That Won't Break Production

A battle-tested migration playbook covering auto-migration pitfalls, manual migration patterns, and a zero-downtime deployment strategy I use across 22+ production apps.

AndroidKotlinArchitectureTesting
Share:

I've shipped 22 apps from Bangkok. Every single one uses Room. And every major version bump taught me the same lesson: migrations that work in staging will murder your production data if you don't treat them like schema surgery.


The Problem Nobody Talks About

Room's

code
fallbackToDestructiveMigration()
is a loaded gun pointed at your users' data. I've seen it wipe 50,000 user records because a developer added a
code
NOT NULL
column without a default value. Auto-migration (
code
@AutoMigration
) looks safe until it silently drops a column you renamed, or fails on a complex type converter change.

The real problem: migrations are code that runs once, on user devices you don't control, with data you can't predict.

My migration strategy has three pillars: deterministic manual migrations, exhaustive pre-deployment validation, and a rollback plan that doesn't require a Play Store update.


Manual Migrations: The Only Thing I Trust

Auto-migration handles simple column additions. It fails on:

  • Column renames (drops old, creates new — data gone)
  • Type changes requiring data transformation
  • Complex index modifications
  • Multi-step schema reorganizations

I write every migration by hand. Here's the pattern I use across all 22 apps:

kotlin
// Migration from version 7 to 8: splitting user_preferences into two tables
val MIGRATION_7_8 = object : Migration(7, 8) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
            CREATE TABLE user_settings_new (
                user_id INTEGER PRIMARY KEY NOT NULL,
                theme_mode INTEGER NOT NULL DEFAULT 0,
                notification_enabled INTEGER NOT NULL DEFAULT 1,
                language_code TEXT NOT NULL DEFAULT 'en',
                FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        """.trimIndent())

        database.execSQL("""
            INSERT INTO user_settings_new (user_id, theme_mode, notification_enabled, language_code)
            SELECT 
                id,
                CASE WHEN dark_mode = 1 THEN 2 ELSE 0 END,
                COALESCE(push_enabled, 1),
                COALESCE(locale, 'en')
            FROM users
        """.trimIndent())

        database.execSQL("DROP TABLE user_preferences")
        database.execSQL("ALTER TABLE user_settings_new RENAME TO user_settings")
    }
}

[!WARNING] Never use

code
database.execSQL("ALTER TABLE ... ADD COLUMN ... NOT NULL")
without a
code
DEFAULT
clause. SQLite will throw
code
NOT NULL constraint failed
on existing rows. Always provide a sensible default or backfill in a separate step.


The Validation Pipeline That Catches Everything

I don't merge a migration until it passes four gates. This runs on every PR:

GateToolWhat It Catches
Compile-time schema check
code
room-schema
export + diff
Missing
code
@Entity
changes, type mismatches
Migration test
code
runMigrationsAndValidate()
on exported schemas
SQL syntax errors, constraint violations
Data integrity testCustom test with 10k synthetic rowsData loss, null violations, FK breaks
Rollback testReverse migration on production-shaped dataIrreversible operations, orphaned data

The migration test is non-negotiable. Here's the exact test I copy into every project:

kotlin
@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

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

    @Test
    fun migrateAllVersions() {
        // Test every version jump from 1 to CURRENT_VERSION
        for (version in 1..AppDatabase.CURRENT_VERSION - 1) {
            val db = helper.createDatabase(TEST_DB, version).apply {
                // Seed realistic production data
                seedProductionData(this, version)
                close()
            }

            // Run migration
            val migratedDb = helper.runMigrationsAndValidate(
                TEST_DB, AppDatabase.CURRENT_VERSION, true, MIGRATIONS
            )

            // Verify data integrity post-migration
            assertDataIntegrity(migratedDb, version)
            migratedDb.close()
        }
    }

    private fun seedProductionData(db: SupportSQLiteDatabase, fromVersion: Int) {
        // Insert 10,000 rows with edge cases: nulls, max lengths, FK chains
        // This catches migrations that work on empty DBs but fail on real data
    }

    private fun assertDataIntegrity(db: SupportSQLiteDatabase, fromVersion: Int) {
        // Row counts match, no nulls in NOT NULL columns, FKs resolve
    }
}

[!TIP] Export your schema JSON (

code
room.schemaLocation
) to
code
schemas/
in your module. Commit it. The
code
MigrationTestHelper
validates migrations against exact historical schemas, not your current entities. This catches drift immediately.


Zero-Downtime Deployment Strategy

You cannot control when users update. A user on v1.2 might open the app after you've shipped v2.0 with a migration from v1.5→v2.0. Room runs all intermediate migrations sequentially. If any single step fails, the app crashes on launch.

My solution: migrations are idempotent and reversible by design.

Idempotent Migration Pattern

kotlin
val MIGRATION_5_6 = object : Migration(5, 6) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Check if migration already applied (idempotency)
        val columnExists = database.query("PRAGMA table_info(events)").use { cursor ->
            while (cursor.moveToNext()) {
                if (cursor.getString(1) == "sync_status") return@use true
            }
            false
        }

        if (!columnExists) {
            database.execSQL("ALTER TABLE events ADD COLUMN sync_status INTEGER NOT NULL DEFAULT 0")
            database.execSQL("CREATE INDEX idx_events_sync ON events(sync_status)")
        }
    }
}

The Rollback Table Pattern

For destructive changes (column drops, table splits), I maintain a shadow table for 2 versions:

kotlin
val MIGRATION_8_9 = object : Migration(8, 9) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Phase 1: Create new structure, keep old data accessible
        database.execSQL("""
            CREATE TABLE analytics_events_v2 (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                event_name TEXT NOT NULL,
                properties_json TEXT NOT NULL DEFAULT '{}',
                timestamp_ms INTEGER NOT NULL,
                session_id TEXT,
                user_id INTEGER,
                FOREIGN KEY(user_id) REFERENCES users(id)
            )
        """.trimIndent())

        // Phase 2: Backfill with transformation
        database.execSQL("""
            INSERT INTO analytics_events_v2 (event_name, properties_json, timestamp_ms, session_id, user_id)
            SELECT 
                event_type,
                json_object('value', CAST(event_value AS TEXT), 'screen', screen_name),
                occurred_at,
                session_token,
                user_id
            FROM analytics_events
        """.trimIndent())

        // Phase 3: Atomic swap (SQLite doesn't support RENAME TABLE atomically with triggers)
        // So we keep both tables for one version, read from v2, write to v2
        // Phase 4 (v9→v10): Drop analytics_events after confirming zero reads
    }
}

[!NOTE] SQLite's

code
ALTER TABLE RENAME
is atomic, but you can't rename a table that has triggers referencing it. Drop triggers first, rename, recreate triggers. I learned this during a 3 AM production incident on a digital signage app with 200k active devices.


Migration Decision Matrix

When a schema change request lands, I classify it before writing code:

Change TypeAuto-MigrationManual MigrationRequires Shadow TableRollback Window
Add nullable column✅ Safe❌ OverkillNo0 versions
Add NOT NULL column + default✅ Safe❌ OverkillNo0 versions
Add NOT NULL column, no default❌ Fails✅ RequiredNo0 versions
Rename column❌ Drops data✅ RequiredYes2 versions
Change column type❌ Fails✅ RequiredYes2 versions
Drop column❌ Drops data✅ RequiredYes2 versions
Split table❌ Fails✅ RequiredYes2 versions
Add index✅ SafeOptionalNo0 versions
Add foreign key⚠️ Validate firstIf validation failsSometimes1 version

[!IMPORTANT] The "Rollback Window" column is how many app versions you must keep the old schema accessible. Never drop a column or table in the same version you stop reading it. Keep it for +2 versions minimum.


Real Incident: The
code
locale
→
code
language_code
Rename

Version 12 had

code
locale TEXT
. Version 13 needed
code
language_code TEXT NOT NULL DEFAULT 'en'
with ISO 639-1 codes.

What auto-migration would do: Drop

code
locale
, create
code
language_code
. All existing user languages lost. Defaults to 'en' for everyone.

What I did:

kotlin
val MIGRATION_12_13 = object : Migration(12, 13) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Step 1: Add new column with default
        database.execSQL("ALTER TABLE users ADD COLUMN language_code TEXT NOT NULL DEFAULT 'en'")

        // Step 2: Backfill with mapping logic
        database.execSQL("""
            UPDATE users SET language_code = CASE
                WHEN locale IN ('en', 'en_US', 'en_GB') THEN 'en'
                WHEN locale IN ('th', 'th_TH') THEN 'th'
                WHEN locale IN ('ja', 'ja_JP') THEN 'ja'
                WHEN locale IN ('zh', 'zh_CN', 'zh_TW') THEN 'zh'
                ELSE 'en'
            END
        """.trimIndent())

        // Step 3: Create index for new query patterns
        database.execSQL("CREATE INDEX idx_users_language ON users(language_code)")

        // Step 4: Keep locale column for v14 rollback safety
        // DROP COLUMN locale happens in MIGRATION_14_15
    }
}

Zero user complaints. Zero data loss. The

code
locale
column stayed for two more versions as insurance.


CI/CD Integration: Fail Fast, Ship Confident

My GitHub Actions workflow blocks merge on any migration failure:

yaml
# .github/workflows/migration-check.yml
name: Migration Validation

on:
  pull_request:
    paths:
      - '**/db/**'
      - '**/Migration*.kt'

jobs:
  validate-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '21'
      
      - name: Export current schema
        run: ./gradlew :app:exportSchema
      
      - name: Run migration tests
        run: ./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sudarshantechlabs.MigrationTest
      
      - name: Verify schema export committed
        run: |
          if ! git diff --quiet schemas/; then
            echo "::error::Schema export changed. Commit the new schema JSON."
            exit 1
          fi

[!TIP] Run migration tests on a real device/emulator in CI (

code
connectedAndroidTest
), not Robolectric. SQLite behavior differs between Android's implementation and the host JVM. I've caught
code
PRAGMA foreign_keys=ON
issues that only appear on device.


Key Takeaways

  • Write every migration manually — auto-migration is for prototyping only. Manual migrations give you control over data transformation, defaults, and rollback safety.
  • Test against production-shaped data — 10k rows with nulls, max lengths, and FK chains. Empty database tests catch syntax errors; realistic data catches logic errors.
  • Keep old schema for +2 versions — never drop a column or table in the same version you stop using it. The rollback window is your insurance against forced updates and staggered rollouts.
  • Make migrations idempotent — check
    code
    PRAGMA table_info
    before
    code
    ALTER TABLE
    . Users skip versions, reinstall, or restore backups. Your migration must handle running twice.
  • Export and commit schema JSON —
    code
    room.schemaLocation
    +
    code
    MigrationTestHelper
    validates every migration against exact historical schemas. This is your regression net.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this