Chapter 11: Migrations & Versioning

1. The Anatomy of Core Data Versioning

  • The entity's name
  • The names, types, and properties of its attributes
  • The names, destinations, and properties of its relationships

[!WARNING] If the hashes do not match perfectly, Core Data determines the store is incompatible. If you forcefully try to load the store without providing a migration path, Core Data throws an NSPersistentStoreIncompatibleVersionHashError, and your app will immediately crash.

Creating a New Model Version

  1. Select your ExpenseTracker.xcdatamodeld package in the Project Navigator.
  2. Go to the Xcode menu bar: Editor > Add Model Version...
  3. Name the new version appropriately (e.g., ExpenseTracker V2) and base it on the current version.
  4. Set the new version as the Current model:
    • Select the .xcdatamodeld package.
    • Open the File Inspector (Right sidebar).
    • Under Model Version, change "Current" to ExpenseTracker V2.

[!CAUTION] Never delete old .xcdatamodel files. If you delete V1, users upgrading directly from V1 to V3 will crash because Core Data cannot find the original schema to map from.

2. The Migration Spectrum

graph TD A[Schema Change Required] --> B{"Are changes simple?"} B -- Yes --> C[Lightweight Migration] B -- No --> D[Custom / Heavyweight Migration] C --> C1("Add/Remove Attributes") C --> C2(Make Mandatory Optional) C --> C3(Rename with Renaming ID) C --> C4(Change Relationship Types) D --> D1("Mapping Models .xcmappingmodel") D --> D2(NSEntityMigrationPolicy) D --> D3(Complex Data Transformations) D --> D4(Splitting/Merging Entities)

Lightweight Migration

  • Adding a new attribute or relationship.
  • Removing an attribute or relationship.
  • Making a non-optional attribute optional.
  • Making an optional attribute non-optional (you must provide a default value).
  • Renaming an entity or attribute (requires setting the Renaming ID in the new model's Data Model Inspector so Core Data knows the old name).

Custom Migration (Heavyweight)

3. Implementing Lightweight Migration

Step 3.1: Update the Model

  1. Create a new model version ExpenseTracker V2 as described above.
  2. Select ExpenseTracker V2.xcdatamodel.
  3. Select the Transaction entity.
  4. Add a new Attribute named notes of type String. Leave it marked as Optional.

Step 3.2: Configure the Core Data Stack

import CoreData
import Foundation

final class CoreDataStack {
    static let shared = CoreDataStack()
    
    let persistentContainer: NSPersistentContainer
    
    private init() {
        persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
        
        // 1. Fetch the default store description
        guard let description = persistentContainer.persistentStoreDescriptions.first else {
            fatalError("Failed to retrieve a persistent store description.")
        }
        
        // 2. Explicitly enable Lightweight Migration features
        description.shouldMigrateStoreAutomatically = true
        description.shouldInferMappingModelAutomatically = true
        
        // 3. Load the persistent store
        persistentContainer.loadPersistentStores { [weak self] (storeDescription, error) in
            if let error = error as NSError? {
                // In production, NEVER use fatalError for Core Data load failures.
                // A failure here often means the migration failed or the store is corrupted.
                // The safest user-facing approach is often to delete the corrupted store and rebuild it,
                // though this results in data loss. Ideally, you should back up the store before attempting this.
                
                print("Core Data failed to load: \(error.localizedDescription)")
                self?.handleFatalCoreDataError(error, storeDescription: storeDescription)
            }
        }
        
        // Setup context optimization and concurrency rules
        persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
        // Trump merge policy ensures that if the UI and a background thread edit the same object,
        // the in-memory UI changes win, preventing weird UI state jumps.
        persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
    
    var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    /// Handles catastrophic initialization failures, such as unrecoverable migration errors.
    private func handleFatalCoreDataError(_ error: NSError, storeDescription: NSPersistentStoreDescription) {
        // Log the error to your analytics platform (e.g., Crashlytics, Datadog)
        // Analytics.recordError(error)
        
        // As a last resort, if the store is completely corrupted and unmigratable, 
        // you may choose to delete it so the user isn't permanently locked out of the app.
        if let url = storeDescription.url {
            do {
                try NSPersistentStoreCoordinator.destroyStore(at: url)
                // Attempt to load again after destroying
                persistentContainer.loadPersistentStores { _, _ in }
            } catch {
                print("Failed to destroy corrupted store: \(error)")
            }
        }
    }
    
    func saveContext() {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nserror = error as NSError
                print("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
}

Step 3.3: Updating the Repository and ViewModel

import Foundation
import CoreData

protocol TransactionRepositoryProtocol {
    func addTransaction(amount: Double, date: Date, category: Category, notes: String?)
    func fetchTransactions() -> [Transaction]
}

class TransactionRepository: TransactionRepositoryProtocol {
    private let context: NSManagedObjectContext
    
    init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
        self.context = context
    }
    
    func addTransaction(amount: Double, date: Date, category: Category, notes: String?) {
        // Perform creation on the context's thread to ensure thread safety.
        context.performAndWait {
            let newTransaction = Transaction(context: context)
            newTransaction.id = UUID()
            newTransaction.amount = amount
            newTransaction.date = date
            newTransaction.category = category
            
            // Populate the newly migrated attribute
            newTransaction.notes = notes 
            
            do {
                try context.save()
            } catch {
                print("Failed to save transaction: \(error)")
            }
        }
    }
    
    func fetchTransactions() -> [Transaction] {
        let request: NSFetchRequest = Transaction.fetchRequest()
        // Always sort at the database level rather than fetching and sorting in memory
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Transaction.date, ascending: false)]
        
        // Optimizing fetch by setting fetchBatchSize ensures we don't blow up memory 
        // if the user has 10,000 transactions.
        request.fetchBatchSize = 20 
        
        do {
            return try context.fetch(request)
        } catch {
            print("Fetch failed: \(error)")
            return []
        }
    }
}
import SwiftUI
import Observation

@Observable class AddTransactionViewModel {
    var amount: String = ""
    var selectedCategory: Category?
    var notes: String = "" // Expose to the view
    
    private let repository: TransactionRepositoryProtocol
    
    init(repository: TransactionRepositoryProtocol = TransactionRepository()) {
        self.repository = repository
    }
    
    func save() {
        guard let amountDouble = Double(amount), let category = selectedCategory else { return }
        
        // Sanitize input before persisting
        let finalNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
        let notesToSave = finalNotes.isEmpty ? nil : finalNotes
        
        repository.addTransaction(
            amount: amountDouble,
            date: Date(),
            category: category,
            notes: notesToSave
        )
    }
}

4. Custom Migrations and Mapping Models

[!CAUTION] Memory & Performance Warning: Custom migrations do not use ALTER TABLE. They create a brand new .sqlite file alongside the old one. Core Data uses the mapping model to fetch old records into memory, transform them, and insert them into the new store. If you have a large dataset, this can cause massive memory spikes (Out Of Memory crashes) and can take several seconds or minutes, leaving the user stuck on the launch screen. Avoid custom migrations if possible. (e.g., Use lightweight migration to add new attributes, and migrate the data lazily in the background over time).

Creating a Mapping Model

  1. Create a new model version (e.g., ExpenseTracker V3) and make your complex changes. Set it as the Current version.
  2. Go to File > New > File...
  3. Select Mapping Model under Core Data.
  4. Choose the Source model (ExpenseTracker V2).
  5. Choose the Target model (ExpenseTracker V3).
  6. Name it V2toV3MappingModel.xcmappingmodel and save it in your project.

Value Expressions

$source.firstName + " " + $source.lastName

Custom Entity Migration Policies

  1. Create a new Swift file for your policy:
import CoreData

class TransactionV2ToV3Policy: NSEntityMigrationPolicy {
    
    override func createDestinationInstances(
        forSource sInstance: NSManagedObject,
        in mapping: NSEntityMapping,
        manager: NSMigrationManager
    ) throws {
        
        // 1. Create the destination instance in the new schema
        let destination = NSEntityDescription.insertNewObject(
            forEntityName: mapping.destinationEntityName!,
            into: manager.destinationContext
        )
        
        // 2. Perform custom data transformations safely
        if let oldAmount = sInstance.value(forKey: "amount") as? Double {
            // V3 stores amounts in Cents instead of Dollars to avoid floating point math errors
            // Use Decimal to safely bridge the conversion without precision loss
            let decimalAmount = Decimal(oldAmount)
            let centsDecimal = decimalAmount * 100
            let amountInCents = NSDecimalNumber(decimal: centsDecimal).intValue
            
            destination.setValue(amountInCents, forKey: "amountInCents")
        }
        
        // Pass over unchanged properties using standard mechanisms
        if let notes = sInstance.value(forKey: "notes") {
            destination.setValue(notes, forKey: "notes")
        }
        
        if let date = sInstance.value(forKey: "date") {
            destination.setValue(date, forKey: "date")
        }
        
        // 3. Re-establish relationships if necessary.
        // Complex relationship mapping often requires overriding `createRelationships(forDestination:...)`
        // But for simple cases, you can pass them through if the destination entity types haven't changed.
        
        // 4. Register the mapping so Core Data knows this source maps to this destination
        manager.associate(
            sourceInstance: sInstance,
            withDestinationInstance: destination,
            for: mapping
        )
    }
}
  1. Open your .xcmappingmodel.
  2. Select the specific Entity Mapping (e.g., TransactionToTransaction).
  3. In the Data Model Inspector (Right panel), enter your class name in the Custom Policy field (e.g., ExpenseTracker.TransactionV2ToV3Policyyou must include the module name!).

5. Migration Paths and Progressive Migration

graph LR V1[Version 1] V2[Version 2] V3[Version 3] V4[Version 4] V1 -.->|"Requires Direct Mapping Model V1->V4"| V4 V2 -.->|"Requires Direct Mapping Model V2->V4"| V4 V3 -->|"Lightweight Migration infers directly"| V4 style V1 fill:#f9d0c4,stroke:#333,stroke-width:2px style V2 fill:#f9d0c4,stroke:#333,stroke-width:2px style V3 fill:#d4edda,stroke:#333,stroke-width:2px

Progressive Migration Strategy

graph LR V1[Version 1] -->|"V1 to V2 Map applied"| V2[Version 2] V2 -->|"V2 to V3 Map applied"| V3[Version 3] V3 -->|"Lightweight applied"| V4[Version 4] style V1 fill:#ffeeba,stroke:#333,stroke-width:2px style V2 fill:#ffeeba,stroke:#333,stroke-width:2px style V3 fill:#ffeeba,stroke:#333,stroke-width:2px style V4 fill:#d4edda,stroke:#333,stroke-width:2px
  • You only ever write one mapping model per new version (V(n-1) -> V(n)).
  • Highly predictable and testable.
  • You have to write custom migration runner code.
  • If a user skips 10 versions, they have to sit through 10 sequential migrations, which takes a long time. You will need to build a UI loading screen to prevent the OS from killing your app for taking too long to launch.

6. Safely Testing Migrations

  1. Check out an older commit of your app (e.g., the V1 commit).
  2. Run the app in the iOS Simulator.
  3. Generate ample sample data (Add numerous Transactions and Categories).
    • Tip: If you are doing a heavy migration, script the creation of 10,000 rows to observe memory behavior.
  4. Stop the app in Xcode.
  5. Check out your latest code (with V2 or V3).
  6. Important: Edit your Xcode Scheme to add -com.apple.CoreData.SQLDebug 1 to the Arguments Passed On Launch. This will print the actual SQL and migration steps Core Data is executing to your console.
  7. Run the app again in the same Simulator without deleting it.
  8. Verify that the app launches successfully, the old data is perfectly mapped to the new UI, and memory footprint stays stable during the migration window.

[!TIP] You can physically inspect the migrated SQLite file. Find your simulator's app container path using xcrun simctl get_app_container booted <bundle_identifier> data. Navigate to the Library/Application Support folder, grab the .sqlite file, and open it using a tool like DB Browser for SQLite. Ensure the new tables and columns look exactly as expected.

Summary

  • We learned how to properly version a model within the .xcdatamodeld package and avoid destroying historical schemas.
  • We implemented a Lightweight Migration to effortlessly and performantly add a notes attribute to our Expense Tracker.
  • We bridged the new data layer changes cleanly through our TransactionRepository and AddTransactionViewModel, proving that MVVM successfully shields our SwiftUI views from database turbulence.
  • We explored the advanced capabilities and memory traps of Custom Migrations, Mapping Models, and NSEntityMigrationPolicy for when schemas change drastically.
  • We contrasted direct vs. progressive migration paths, highlighting architecture strategies for long-lived applications.