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
- Select your
ExpenseTracker.xcdatamodeldpackage in the Project Navigator. - Go to the Xcode menu bar: Editor > Add Model Version...
- Name the new version appropriately (e.g.,
ExpenseTracker V2) and base it on the current version. - Set the new version as the Current model:
- Select the
.xcdatamodeldpackage. - Open the File Inspector (Right sidebar).
- Under Model Version, change "Current" to
ExpenseTracker V2.
- Select the
[!CAUTION] Never delete old
.xcdatamodelfiles. 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
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
- Create a new model version
ExpenseTracker V2as described above. - Select
ExpenseTracker V2.xcdatamodel. - Select the
Transactionentity. - Add a new Attribute named
notesof typeString. 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.sqlitefile 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
- Create a new model version (e.g.,
ExpenseTracker V3) and make your complex changes. Set it as the Current version. - Go to File > New > File...
- Select Mapping Model under Core Data.
- Choose the Source model (
ExpenseTracker V2). - Choose the Target model (
ExpenseTracker V3). - Name it
V2toV3MappingModel.xcmappingmodeland save it in your project.
Value Expressions
$source.firstName + " " + $source.lastName
Custom Entity Migration Policies
- 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
)
}
}
- Open your
.xcmappingmodel. - Select the specific Entity Mapping (e.g.,
TransactionToTransaction). - In the Data Model Inspector (Right panel), enter your class name in the Custom Policy field (e.g.,
ExpenseTracker.TransactionV2ToV3Policy— you must include the module name!).
5. Migration Paths and Progressive Migration
Progressive Migration Strategy
- 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
- Check out an older commit of your app (e.g., the V1 commit).
- Run the app in the iOS Simulator.
- 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.
- Stop the app in Xcode.
- Check out your latest code (with V2 or V3).
- Important: Edit your Xcode Scheme to add
-com.apple.CoreData.SQLDebug 1to the Arguments Passed On Launch. This will print the actual SQL and migration steps Core Data is executing to your console. - Run the app again in the same Simulator without deleting it.
- 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 theLibrary/Application Supportfolder, grab the.sqlitefile, 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
.xcdatamodeldpackage and avoid destroying historical schemas. - We implemented a Lightweight Migration to effortlessly and performantly add a
notesattribute to our Expense Tracker. - We bridged the new data layer changes cleanly through our
TransactionRepositoryandAddTransactionViewModel, 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
NSEntityMigrationPolicyfor when schemas change drastically. - We contrasted direct vs. progressive migration paths, highlighting architecture strategies for long-lived applications.