Mobile App Database Migration Strategies

Database migrations on mobile are uniquely challenging. Unlike server-side migrations where you control the environment and can run scripts sequentially, mobile migrations run on millions of devices with different database states, OS versions, and storage constraints. A failed migration means data loss for real users who cannot call your DBA for recovery.

The stakes are high. A migration bug in version 2.3 of your app might not surface until a user who has been on version 1.0 finally updates — skipping three intermediate schema versions. Your migration code must handle every possible upgrade path, not just the latest one.

This guide covers practical migration strategies for the most common mobile databases: Core Data (iOS), Room (Android), Realm (cross-platform), and raw SQLite.

Core Data Migrations on iOS

Core Data Migrations on iOS Infographic

Core Data provides two migration approaches: lightweight (automatic) and heavyweight (manual).

Lightweight Migration

Core Data can automatically handle simple schema changes:

  • Adding new attributes with default values
  • Removing attributes
  • Renaming attributes (with a renaming identifier)
  • Adding new entities
  • Adding or removing relationships
class PersistenceController {
    let container: NSPersistentContainer

    init() {
        container = NSPersistentContainer(name: "MyApp")

        // Enable automatic lightweight migration
        let description = container.persistentStoreDescriptions.first
        description?.setOption(
            true as NSNumber,
            forKey: NSMigratePersistentStoresAutomaticallyOption
        )
        description?.setOption(
            true as NSNumber,
            forKey: NSInferMappingModelAutomaticallyOption
        )

        container.loadPersistentStores { description, error in
            if let error = error {
                // Handle migration failure
                self.handleMigrationFailure(error)
            }
        }
    }

    private func handleMigrationFailure(_ error: Error) {
        // Log the error for diagnostics
        Logger.error("Core Data migration failed: \(error)")

        // Option 1: Delete and recreate (lose data)
        // Option 2: Copy to backup, delete, recreate, restore what we can
        // Option 3: Show error to user with recovery instructions
    }
}

To rename an attribute while preserving data, set the Renaming Identifier in the Xcode model editor. For example, renaming name to displayName: set the Renaming Identifier of displayName to name.

Heavyweight Migration with Mapping Models

When schema changes are too complex for lightweight migration — like splitting an entity into two, merging entities, or transforming data — you need a mapping model:

// Custom migration policy
class OrderMigrationPolicy: NSEntityMigrationPolicy {
    override func createDestinationInstances(
        forSource sourceInstance: NSManagedObject,
        in mapping: NSEntityMapping,
        manager: NSMigrationManager
    ) throws {
        // Create the new Order entity
        let destinationOrder = NSEntityDescription.insertNewObject(
            forEntityName: "Order",
            into: manager.destinationContext
        )

        // Copy basic attributes
        destinationOrder.setValue(
            sourceInstance.value(forKey: "orderId"),
            forKey: "id"
        )
        destinationOrder.setValue(
            sourceInstance.value(forKey: "orderDate"),
            forKey: "createdAt"
        )

        // Transform data: split full name into first and last
        if let fullName = sourceInstance.value(forKey: "customerName") as? String {
            let components = fullName.split(separator: " ")
            destinationOrder.setValue(
                String(components.first ?? ""),
                forKey: "customerFirstName"
            )
            destinationOrder.setValue(
                components.dropFirst().joined(separator: " "),
                forKey: "customerLastName"
            )
        }

        // Calculate new field from existing data
        if let subtotal = sourceInstance.value(forKey: "subtotal") as? Double {
            let gst = subtotal / 11.0 // Australian GST
            destinationOrder.setValue(gst, forKey: "gstAmount")
        }

        manager.associate(
            sourceInstance: sourceInstance,
            withDestinationInstance: destinationOrder,
            for: mapping
        )
    }
}

Progressive Migration

When users might skip multiple versions, chain migrations through intermediate models:

class MigrationManager {
    private let modelVersions = ["Model_v1", "Model_v2", "Model_v3", "Model_v4"]

    func migrateStoreIfNeeded(at storeURL: URL) throws {
        let metadata = try NSPersistentStoreCoordinator
            .metadataForPersistentStore(
                ofType: NSSQLiteStoreType,
                at: storeURL
            )

        // Find the source model version
        guard let sourceVersionIndex = modelVersions.firstIndex(where: { version in
            let model = managedObjectModel(named: version)
            return model.isConfiguration(
                withName: nil,
                compatibleWithStoreMetadata: metadata
            )
        }) else {
            throw MigrationError.incompatibleStore
        }

        // Already on latest version
        let currentIndex = modelVersions.count - 1
        if sourceVersionIndex == currentIndex { return }

        // Migrate through each version step
        for i in sourceVersionIndex..<currentIndex {
            let sourceModel = managedObjectModel(named: modelVersions[i])
            let destinationModel = managedObjectModel(named: modelVersions[i + 1])

            try migrateStore(
                at: storeURL,
                from: sourceModel,
                to: destinationModel,
                mappingModelName: "\(modelVersions[i])_to_\(modelVersions[i + 1])"
            )
        }
    }
}

Room Database Migrations on Android

Room Database Migrations on Android Infographic

Room uses numbered database versions with explicit migration paths.

Basic Migration

@Database(
    entities = [User::class, Order::class, Product::class],
    version = 4,
    exportSchema = true // Always export for migration testing
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun orderDao(): OrderDao

    companion object {
        fun build(context: Context): AppDatabase {
            return Room.databaseBuilder(
                context,
                AppDatabase::class.java,
                "app_database"
            )
                .addMigrations(
                    MIGRATION_1_2,
                    MIGRATION_2_3,
                    MIGRATION_3_4
                )
                .build()
        }

        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // Add new column
                database.execSQL(
                    "ALTER TABLE users ADD COLUMN profile_image_url TEXT"
                )
            }
        }

        val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // Create new table
                database.execSQL("""
                    CREATE TABLE IF NOT EXISTS orders (
                        id TEXT PRIMARY KEY NOT NULL,
                        user_id TEXT NOT NULL,
                        total_amount REAL NOT NULL,
                        gst_amount REAL NOT NULL,
                        status TEXT NOT NULL DEFAULT 'pending',
                        created_at INTEGER NOT NULL,
                        FOREIGN KEY(user_id) REFERENCES users(id)
                    )
                """)
                database.execSQL(
                    "CREATE INDEX index_orders_user_id ON orders(user_id)"
                )
            }
        }

        val MIGRATION_3_4 = object : Migration(3, 4) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // Complex migration: rename column and add new ones
                // SQLite doesn't support ALTER COLUMN, so recreate the table

                // 1. Create new table with desired schema
                database.execSQL("""
                    CREATE TABLE users_new (
                        id TEXT PRIMARY KEY NOT NULL,
                        display_name TEXT NOT NULL,
                        email TEXT NOT NULL,
                        profile_image_url TEXT,
                        created_at INTEGER NOT NULL DEFAULT 0,
                        updated_at INTEGER NOT NULL DEFAULT 0
                    )
                """)

                // 2. Copy data from old table
                database.execSQL("""
                    INSERT INTO users_new (id, display_name, email, profile_image_url)
                    SELECT id, name, email, profile_image_url FROM users
                """)

                // 3. Drop old table
                database.execSQL("DROP TABLE users")

                // 4. Rename new table
                database.execSQL("ALTER TABLE users_new RENAME TO users")
            }
        }
    }
}

Auto-Migration (Room 2.4+)

Room 2.4 introduced auto-migrations for simple schema changes:

@Database(
    entities = [User::class, Order::class],
    version = 5,
    autoMigrations = [
        AutoMigration(from = 4, to = 5, spec = AutoMigration4To5::class)
    ]
)
abstract class AppDatabase : RoomDatabase()

@RenameColumn(
    tableName = "users",
    fromColumnName = "full_name",
    toColumnName = "display_name"
)
class AutoMigration4To5 : AutoMigrationSpec

Testing Migrations

Room provides excellent migration testing support:

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )

    @Test
    fun migrate2To3() {
        // Create database at version 2
        var db = helper.createDatabase(TEST_DB, 2)

        // Insert test data
        db.execSQL("""
            INSERT INTO users (id, name, email, profile_image_url)
            VALUES ('1', 'Test User', '[email protected]', null)
        """)
        db.close()

        // Migrate to version 3
        db = helper.runMigrationsAndValidate(
            TEST_DB, 3, true, AppDatabase.MIGRATION_2_3
        )

        // Verify migration succeeded
        val cursor = db.query("SELECT * FROM orders")
        assertEquals(0, cursor.count) // New table exists but is empty
        cursor.close()

        // Verify existing data preserved
        val userCursor = db.query("SELECT * FROM users WHERE id = '1'")
        assertTrue(userCursor.moveToFirst())
        assertEquals("Test User", userCursor.getString(
            userCursor.getColumnIndex("name")
        ))
        userCursor.close()
    }

    @Test
    fun migrateAllVersions() {
        // Test the complete migration path from version 1 to latest
        helper.createDatabase(TEST_DB, 1).close()

        helper.runMigrationsAndValidate(
            TEST_DB,
            4, // Latest version
            true,
            AppDatabase.MIGRATION_1_2,
            AppDatabase.MIGRATION_2_3,
            AppDatabase.MIGRATION_3_4
        )
    }
}

Rea

lm Migrations

Realm uses a schema version number with a migration block:

// iOS Realm migration
let config = Realm.Configuration(
    schemaVersion: 4,
    migrationBlock: { migration, oldSchemaVersion in
        if oldSchemaVersion < 2 {
            // Add new property (Realm handles adding with default values automatically)
            // Only need migration block for data transformation
            migration.enumerateObjects(ofType: User.className()) { oldObject, newObject in
                let firstName = oldObject?["firstName"] as? String ?? ""
                let lastName = oldObject?["lastName"] as? String ?? ""
                newObject?["displayName"] = "\(firstName) \(lastName)"
            }
        }

        if oldSchemaVersion < 3 {
            migration.enumerateObjects(ofType: Order.className()) { oldObject, newObject in
                if let total = oldObject?["total"] as? Double {
                    newObject?["gstAmount"] = total / 11.0
                }
            }
        }

        if oldSchemaVersion < 4 {
            // Rename property
            migration.renameProperty(
                onType: User.className(),
                from: "emailAddress",
                to: "email"
            )
        }
    }
)

Realm.Configuration.defaultConfiguration = config

Migration Best Pr

actices

1. Always Test Migration Paths

Test every possible upgrade path, not just version N to N+1. A user on version 1 might skip directly to version 5.

2. Back Up Before Migrating

func backupDatabase(at url: URL) throws -> URL {
    let backupURL = url.deletingLastPathComponent()
        .appendingPathComponent("database_backup.sqlite")
    try FileManager.default.copyItem(at: url, to: backupURL)
    return backupURL
}

3. Handle Migration Failures Gracefully

fun buildDatabase(context: Context): AppDatabase {
    return try {
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
            .addMigrations(*allMigrations)
            .build()
    } catch (e: Exception) {
        Logger.error("Migration failed, falling back to destructive", e)
        // Last resort: recreate database
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
            .fallbackToDestructiveMigration()
            .build()
    }
}

4. Export Schema for Version Control

Always export your database schema to version control. Room’s exportSchema = true creates JSON schema files. For Core Data, keep each .xcdatamodel version in your repository.

5. Monitor Migration Performance

Large databases can take seconds to migrate. Show progress to users:

func migrateWithProgress() async {
    await MainActor.run { migrationState = .inProgress(0) }

    // Perform migration steps
    for (index, step) in migrationSteps.enumerated() {
        try await step.execute()
        let progress = Double(index + 1) / Double(migrationSteps.count)
        await MainActor.run { migrationState = .inProgress(progress) }
    }

    await MainActor.run { migrationState = .complete }
}

6. Plan for Skip-Version Migrations

Structure your migrations so they can be chained. Each migration should be self-contained and idempotent where possible.

Database migrations are infrastructure work that users never see but immediately feel when it goes wrong. Invest in testing, handle failures gracefully, and always provide a recovery path. Your users’ data is the most valuable thing in your app.


Need help managing database migrations in your mobile app? Our team at eawesome builds robust data layers for Australian mobile applications.