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.
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
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.
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.
Encryption at Rest: Protecting Data on iOS Devices
Implement encryption at rest for iOS applications using Apple's CryptoKit, AES-GCM, and the Secure Enclave, with practical Swift code examples.