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.
A battle-tested migration playbook covering auto-migration pitfalls, manual migration patterns, and a zero-downtime deployment strategy I use across 22+ production apps.
On this page
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.
Room's
fallbackToDestructiveMigration()NOT NULL@AutoMigrationThe 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.
Auto-migration handles simple column additions. It fails on:
I write every migration by hand. Here's the pattern I use across all 22 apps:
// 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
without acodedatabase.execSQL("ALTER TABLE ... ADD COLUMN ... NOT NULL")clause. SQLite will throwcodeDEFAULTon existing rows. Always provide a sensible default or backfill in a separate step.codeNOT NULL constraint failed
I don't merge a migration until it passes four gates. This runs on every PR:
| Gate | Tool | What It Catches |
|---|---|---|
| Compile-time schema check | code | Missing code |
| Migration test | code | SQL syntax errors, constraint violations |
| Data integrity test | Custom test with 10k synthetic rows | Data loss, null violations, FK breaks |
| Rollback test | Reverse migration on production-shaped data | Irreversible operations, orphaned data |
The migration test is non-negotiable. Here's the exact test I copy into every project:
@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 (
) tocoderoom.schemaLocationin your module. Commit it. Thecodeschemas/validates migrations against exact historical schemas, not your current entities. This catches drift immediately.codeMigrationTestHelper
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.
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)")
}
}
}For destructive changes (column drops, table splits), I maintain a shadow table for 2 versions:
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
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.codeALTER TABLE RENAME
When a schema change request lands, I classify it before writing code:
| Change Type | Auto-Migration | Manual Migration | Requires Shadow Table | Rollback Window |
|---|---|---|---|---|
| Add nullable column | ✅ Safe | ❌ Overkill | No | 0 versions |
| Add NOT NULL column + default | ✅ Safe | ❌ Overkill | No | 0 versions |
| Add NOT NULL column, no default | ❌ Fails | ✅ Required | No | 0 versions |
| Rename column | ❌ Drops data | ✅ Required | Yes | 2 versions |
| Change column type | ❌ Fails | ✅ Required | Yes | 2 versions |
| Drop column | ❌ Drops data | ✅ Required | Yes | 2 versions |
| Split table | ❌ Fails | ✅ Required | Yes | 2 versions |
| Add index | ✅ Safe | Optional | No | 0 versions |
| Add foreign key | ⚠️ Validate first | If validation fails | Sometimes | 1 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.
localelanguage_codeVersion 12 had
locale TEXTlanguage_code TEXT NOT NULL DEFAULT 'en'What auto-migration would do: Drop
localelanguage_codeWhat I did:
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
localeMy GitHub Actions workflow blocks merge on any migration failure:
# .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 (
), not Robolectric. SQLite behavior differs between Android's implementation and the host JVM. I've caughtcodeconnectedAndroidTestissues that only appear on device.codePRAGMA foreign_keys=ON
PRAGMA table_infoALTER TABLEroom.schemaLocationMigrationTestHelperSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read