
Mobile Database Migration: Prevent Crash in Production
Every mobile developer who ships an app with local persistence will eventually face the same nightmare: you push an update with a schema change, and suddenly your crash-free rate plummets. A mobile app database migration crash is one of the most devastating production bugs — it hits users immediately on update, often before your app even finishes launching, and leaves them with a broken app and no way to recover. Whether you're using Room on Android, Core Data on iOS, Realm, or SQLite via any framework, schema migrations are the silent killer lurking in every release. In this guide, we'll cover exactly how to prevent database migration crashes across all major mobile platforms, test your migrations properly, and catch failures before your users do.
Why Database Migrations Crash in Production
Schema migrations are deceptively dangerous because they behave perfectly in development but crash catastrophically in production. The root cause is almost always a gap between the assumptions you made during development and the reality of your users' devices.
On a developer's device, the database is frequently wiped, recreated, and migrated from the immediate previous version. In production, users may be upgrading from a version that's six months old — or even older. They might skip multiple versions entirely. A migration path that works from v3 to v4 can fail completely when a user jumps from v1 to v4, because the intermediate schema states you tested against never existed on their device.
According to Android's Room documentation, Room requires you to declare explicit migration paths for every version change. If a migration is missing, the app crashes with an IllegalStateException. Similarly, Apple's Core Data documentation warns that migration failures are often silent during development because the persistent store is recreated automatically, masking the crash that would occur on a real user's device.
Other common triggers include: disk-full conditions during migration, corrupted databases from previous crashes, migration code that references columns that don't exist yet in the source schema, and migration logic that works on the main thread and triggers an ANR on slower devices. Each of these scenarios is easy to overlook in testing but devastating in the field.
Android Room: Safe Migration Patterns
Room is Android's de facto persistence library, and its migration system is powerful but unforgiving. Every time you increment the @Database version, you must provide a Migration object for every possible upgrade path. Missing even one path results in a crash.
The Fallback Trap
Many developers reach for fallbackToDestructiveMigration() as a quick fix. This method tells Room to destroy and recreate the database if no migration path is found. While it prevents the crash, it also deletes all user data — a catastrophic outcome that will generate support tickets and one-star reviews:
// DANGEROUS: Destroys all user data
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()According to Google's Room migration testing guide, you should always write explicit migrations and test them against real database files exported from your production app. The recommended pattern is to export schemas at each version and use Room's migration testing helper:
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@Test
fun migrate1To2() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
// Create v1 database
val db = helper.createDatabase(TEST_DB, 1).apply { close() }
// Migrate to v2
helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
}
}Safe Migration Strategies for Room
Write migrations for every version pair. Even if your migration from v3 to v4 is a no-op (no schema changes), declare it. Missing migrations are the number one cause of Room crashes in production.
Use AUTO migrations where possible. Room 2.4+ supports auto-migrations for simple schema changes like adding columns with default values or renaming tables. Auto-migrations are less error-prone than hand-written SQL:
@Database(
version = 2,
entities = [User::class],
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()Export your schemas. Enable room.schemaLocation in your build.gradle and commit the generated JSON schema files. These files are essential for testing migrations and understanding what your production database actually looks like at each version.
iOS Core Data: Migration Without the Pain
Core Data's migration system is entirely different from Room's but just as prone to production crashes. Core Data offers two migration paths: lightweight migration and heavy (manual) migration.
Lightweight Migration Pitfalls
Lightweight migration is the "it just works" option — Core Data infers the schema changes and handles them automatically. Enable it with:
let container = NSPersistentContainer(name: "MyModel")
let description = container.persistentStoreDescriptions.first
description?.shouldInferMappingModelAutomatically = true
description?.shouldMigrateStoreAutomatically = true
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Migration failed: \(error)")
}
}The trap here is that fatalError call. In development, Core Data often recreates the store silently. In production, if lightweight migration fails — for example, because a user's database has unexpected data that doesn't match your model — the app crashes instantly. Apple's Core Data Programming Guide recommends always handling migration failures gracefully by falling back to a fresh store creation, but with proper error reporting:
container.loadPersistentStores { description, error in
if let error = error as NSError? {
// Log the error to your crash monitoring tool
CrashReporter.log(error)
// Attempt recovery by removing the corrupted store
try? container.persistentStoreCoordinator.destroyPersistentStore(
at: description.url!,
ofType: description.type,
options: nil
)
// Retry with fresh store
container.loadPersistentStores { _, retryError in
// Handle second failure
}
}
}Heavy Migration with NSMigrationManager
When your schema changes are too complex for lightweight migration, you need a mapping model and NSMigrationManager. The most common production crash here is memory exhaustion — NSMigrationManager loads the entire source database into memory by default. For large databases, this triggers an OOM crash. Use batch migration with NSFetchRequest batching to process records in chunks:
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Entity")
fetchRequest.fetchBatchSize = 100 // Process 100 records at a timeCross-Platform: Flutter and React Native
If you're building with Flutter, the sqflite package provides the onUpgrade callback for handling schema changes:
final db = await openDatabase(
path,
version: 3,
onCreate: (db, version) { /* ... */ },
onUpgrade: (db, oldVersion, newVersion) {
if (oldVersion < 2) {
db.execute('ALTER TABLE users ADD COLUMN email TEXT');
}
if (oldVersion < 3) {
db.execute('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
}
},
);The critical mistake Flutter developers make is assuming onUpgrade runs in a single transaction. It doesn't. If the app is killed mid-migration, the database can be left in a partially migrated state. Wrap your migrations in a transaction:
onUpgrade: (db, oldVersion, newVersion) async {
await db.transaction((txn) async {
if (oldVersion < 2) {
await txn.execute('ALTER TABLE users ADD COLUMN email TEXT');
}
});
},For React Native apps using libraries like WatermelonDB or TypeORM backed by SQLite, the same principles apply: always version your schema, always write explicit migration paths, and never assume the onUpgrade callback will complete atomically.
Testing Migrations Like Your Users Depend On It
The single most effective thing you can do to prevent database migration crashes is to test migrations against real production databases — not freshly created ones.
Export production databases. Set up a mechanism to export anonymized database files from production builds. Strip any PII (personally identifiable information) but keep the schema and data volume intact. Use these files in your CI pipeline.
Test every upgrade path. If your app is on v10 and you have users on v3, test the v3→v10 migration path. This is where most production crashes hide — in the long-tail upgrade paths you never thought to test.
Test on low-end devices. Database migrations are I/O and memory intensive. A migration that takes 200ms on a flagship phone might take 5 seconds and trigger an ANR on a budget device. Run your migration tests on a range of devices, including older models with limited RAM and slower storage.
Test with large datasets. Your development database probably has 20 records. A power user's database might have 50,000. That ALTER TABLE that takes 10ms in dev can block the main thread for seconds in production. Use Room's MigrationTestHelper, or generate test databases with realistic data volumes.
Catching Migration Crashes in Production
No matter how thoroughly you test, some migration failures will slip through — a corrupted database on a user's device, an OS version you didn't test, or an edge case in a third-party database library. This is where production crash monitoring becomes essential.
When a migration crash occurs, you need the full picture: the stack trace showing exactly which migration step failed, the source schema version, the target version, the device model and OS version, and ideally the error message from the database engine itself. Generic crash reporting tools that only capture the exception without database context leave you guessing.
At Bugspulse, we built our mobile crash monitoring to capture the full context around database failures — including the schema version mismatch, the specific SQL or migration step that failed, and the device conditions that led to the crash. This means you can triage a migration crash in minutes instead of spending hours trying to reproduce it locally. Our privacy-first analytics platform ensures you get all this detail without compromising user privacy.
Key Takeaways
Database migration crashes are preventable if you follow a few core principles. Always declare explicit migration paths — never rely on destructive fallbacks or automatic behavior alone. Test migrations against production-exported databases, not just freshly created ones. Test every upgrade path, especially the multi-version jumps. Run migration tests on low-end devices with realistic data volumes. And when — not if — a migration issue reaches production, make sure you have crash monitoring that captures database context, not just a generic stack trace.
Your users trust you with their data. A migration crash doesn't just break the app — it can lose their data permanently. Ship schema changes with confidence by combining safe migration patterns, comprehensive testing, and production monitoring that catches failures before they become widespread.
Ready to catch database migration crashes before your users report them? Start monitoring with Bugspulse free and ship your next schema change with confidence.