Data Migration Patterns for iOS Apps

Strategies for migrating local data across app versions in iOS, covering versioned schemas, migration pipelines, rollback safety, and how KlivvrStorageKit handles schema evolution.

technical6 min readBy Klivvr Engineering
Share:

Mobile apps evolve. Data models change. Fields are added, renamed, or removed. Data types shift from strings to structured objects. What was a flat dictionary becomes a nested model. Each of these changes creates a migration problem: the data stored on a user's device was written under an older schema, and the new app version needs to read it under a new one.

Unlike server-side migrations where you control the database, mobile data migration must handle the reality that users skip versions. A user on v1.2 might update directly to v2.0, skipping every intermediate release. The migration system must handle any source version gracefully. This article covers the migration patterns KlivvrStorageKit uses to manage schema evolution safely.

Versioned Storage Schemas

Every data model in KlivvrStorageKit carries a version number. When the schema changes, the version increments, and a migration function is registered to transform data from the old version to the new one.

protocol VersionedModel: Codable {
    static var schemaVersion: Int { get }
    static var modelIdentifier: String { get }
}
 
struct UserProfile: VersionedModel {
    static let schemaVersion = 3
    static let modelIdentifier = "user_profile"
 
    let userId: String
    let displayName: String
    let email: String
    let preferences: UserPreferences  // Added in v2
    let notificationSettings: NotificationSettings  // Added in v3
}
 
struct UserPreferences: Codable {
    let theme: String
    let language: String
    let currency: String
}
 
struct NotificationSettings: Codable {
    let pushEnabled: Bool
    let emailEnabled: Bool
    let transactionAlerts: Bool
    let marketingOptIn: Bool
}

Migration Pipeline

Migrations are registered as ordered steps. Each step knows how to transform data from version N to version N+1. When a user opens the app, the migration pipeline detects the stored schema version and runs all necessary migrations in sequence.

typealias MigrationStep = (Data) throws -> Data
 
final class MigrationPipeline {
    private var migrations: [String: [Int: MigrationStep]] = [:]
 
    func register(
        model: String,
        fromVersion: Int,
        migration: @escaping MigrationStep
    ) {
        if migrations[model] == nil {
            migrations[model] = [:]
        }
        migrations[model]?[fromVersion] = migration
    }
 
    func migrate(
        model: String,
        data: Data,
        fromVersion: Int,
        toVersion: Int
    ) throws -> Data {
        guard fromVersion < toVersion else { return data }
 
        var currentData = data
        var currentVersion = fromVersion
 
        while currentVersion < toVersion {
            guard let step = migrations[model]?[currentVersion] else {
                throw MigrationError.missingMigration(
                    model: model,
                    from: currentVersion,
                    to: currentVersion + 1
                )
            }
 
            currentData = try step(currentData)
            currentVersion += 1
        }
 
        return currentData
    }
}
 
enum MigrationError: Error {
    case missingMigration(model: String, from: Int, to: Int)
    case corruptedData(model: String, version: Int)
    case migrationFailed(model: String, from: Int, reason: String)
}

Defining Migrations

Each migration step manipulates the raw JSON data to transform it from one schema version to the next. Working with raw JSON rather than typed models ensures that migrations can handle schemas that no longer exist in the current codebase.

// Register migrations for UserProfile
let pipeline = MigrationPipeline()
 
// v1 -> v2: Add preferences with defaults
pipeline.register(model: "user_profile", fromVersion: 1) { data in
    var json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
 
    // Add default preferences for existing users
    json["preferences"] = [
        "theme": "system",
        "language": Locale.current.languageCode ?? "en",
        "currency": "USD"
    ]
 
    return try JSONSerialization.data(withJSONObject: json)
}
 
// v2 -> v3: Add notification settings with defaults
pipeline.register(model: "user_profile", fromVersion: 2) { data in
    var json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
 
    json["notificationSettings"] = [
        "pushEnabled": true,
        "emailEnabled": true,
        "transactionAlerts": true,
        "marketingOptIn": false  // Default opt-out for marketing
    ]
 
    return try JSONSerialization.data(withJSONObject: json)
}

Safe Migration with Backup

Migrations can fail. Corrupted data, unexpected null values, or bugs in migration logic can all cause problems. KlivvrStorageKit protects against data loss by creating a backup before running migrations and rolling back if any step fails.

final class SafeMigrationExecutor {
    private let storage: KeychainStore
    private let pipeline: MigrationPipeline
    private let backupStore: EncryptedFileStore
 
    func executeMigration<T: VersionedModel>(
        for type: T.Type,
        key: String
    ) throws -> T {
        let storedVersion = try getStoredVersion(for: key)
        let targetVersion = T.schemaVersion
 
        guard storedVersion < targetVersion else {
            // No migration needed
            return try storage.retrieve(T.self, forKey: key)
        }
 
        // Step 1: Read raw data
        guard let rawData = try storage.readRaw(forKey: key) else {
            throw MigrationError.corruptedData(
                model: T.modelIdentifier,
                version: storedVersion
            )
        }
 
        // Step 2: Create backup
        let backupKey = "\(key)_backup_v\(storedVersion)"
        try backupStore.write(rawData, forKey: backupKey)
 
        // Step 3: Run migration pipeline
        do {
            let migratedData = try pipeline.migrate(
                model: T.modelIdentifier,
                data: rawData,
                fromVersion: storedVersion,
                toVersion: targetVersion
            )
 
            // Step 4: Validate migrated data can decode
            let result = try JSONDecoder().decode(T.self, from: migratedData)
 
            // Step 5: Write migrated data
            try storage.saveRaw(migratedData, forKey: key)
            try setStoredVersion(targetVersion, for: key)
 
            // Step 6: Clean up backup
            try? backupStore.delete(forKey: backupKey)
 
            return result
 
        } catch {
            // Rollback: restore from backup
            if let backup = try? backupStore.read(forKey: backupKey) {
                try? storage.saveRaw(backup, forKey: key)
            }
            throw MigrationError.migrationFailed(
                model: T.modelIdentifier,
                from: storedVersion,
                reason: error.localizedDescription
            )
        }
    }
 
    private func getStoredVersion(for key: String) throws -> Int {
        let versionKey = "\(key)_schema_version"
        return try storage.retrieve(Int.self, forKey: versionKey) ?? 1
    }
 
    private func setStoredVersion(_ version: Int, for key: String) throws {
        let versionKey = "\(key)_schema_version"
        try storage.store(version, forKey: versionKey)
    }
}

Testing Migrations

Every migration must be tested with real data from previous schema versions. We maintain test fixtures — JSON snapshots of data as it appeared in each version — and verify that migrations produce correct output.

final class MigrationTests: XCTestCase {
    let pipeline = MigrationPipeline()
 
    override func setUp() {
        super.setUp()
        registerAllMigrations(pipeline)
    }
 
    func testMigrationV1ToV3() throws {
        // v1 data fixture
        let v1Data = """
        {
            "userId": "usr_123",
            "displayName": "Ahmed",
            "email": "ahmed@example.com"
        }
        """.data(using: .utf8)!
 
        let result = try pipeline.migrate(
            model: "user_profile",
            data: v1Data,
            fromVersion: 1,
            toVersion: 3
        )
 
        let profile = try JSONDecoder().decode(UserProfile.self, from: result)
 
        XCTAssertEqual(profile.userId, "usr_123")
        XCTAssertEqual(profile.preferences.theme, "system")
        XCTAssertTrue(profile.notificationSettings.transactionAlerts)
        XCTAssertFalse(profile.notificationSettings.marketingOptIn)
    }
 
    func testMigrationPreservesExistingData() throws {
        let v2Data = """
        {
            "userId": "usr_456",
            "displayName": "Sara",
            "email": "sara@example.com",
            "preferences": {
                "theme": "dark",
                "language": "ar",
                "currency": "EGP"
            }
        }
        """.data(using: .utf8)!
 
        let result = try pipeline.migrate(
            model: "user_profile",
            data: v2Data,
            fromVersion: 2,
            toVersion: 3
        )
 
        let profile = try JSONDecoder().decode(UserProfile.self, from: result)
 
        // Existing data preserved
        XCTAssertEqual(profile.preferences.theme, "dark")
        XCTAssertEqual(profile.preferences.currency, "EGP")
 
        // New fields added with defaults
        XCTAssertTrue(profile.notificationSettings.pushEnabled)
    }
}

Conclusion

Data migration is an unavoidable part of mobile development. Every schema change is a contract change with data that already exists on millions of devices. KlivvrStorageKit's migration pipeline handles this through versioned schemas, sequential migration steps, backup-and-rollback safety, and comprehensive test fixtures. The key principle is simple: never trust that migration will succeed, always have a way back, and test with data from every version you have ever shipped.

Related Articles

business

Building Internal SDKs That Developers Want to Use

Lessons from building KlivvrStorageKit on creating internal SDKs with great developer experience — covering API design, documentation, migration support, and adoption strategies.

6 min read
technical

iOS Keychain Services: A Comprehensive Guide

Master iOS Keychain Services with this comprehensive guide covering item classes, access control, sharing, migration, and common pitfalls in Swift.

8 min read