Overview

A migration updates the on-disk store to a new model version without losing user data. A released app that cannot open its own store crashes at launch, so treat every model change as a migration. Stack setup is in core-data; for SwiftData schema evolution see swiftdata.

Enable automatic lightweight migration

Set both flags on the store description before loading stores. Core Data then infers the mapping for supported changes.

let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true

Migration runs inside loadPersistentStores, so load asynchronously for large stores (core-data).

Add a model version for every shipped structural change

In Xcode choose Editor > Add Model Version. Never edit a version that any user has installed. Set the new version as Current in the model inspector; Core Data reads it as the target schema.

Model.xcdatamodeld/
  Model.xcdatamodel      v1, never edit
  Model v2.xcdatamodel   additive
  Model v3.xcdatamodel   current

Know what needs more than lightweight

Lightweight migration covers additive and rename-style changes: adding or removing attributes and relationships, changing optionality (with a default when going to required), and renaming an entity or attribute through its renaming identifier. Splitting or merging entities, transforming values or types, and deriving a value from several attributes need custom logic.

Use staged migration to mix lightweight and custom steps

Staged migration (iOS 17+) lets you describe a sequence of stages with NSStagedMigrationManager: NSLightweightMigrationStage for inferable hops and NSCustomMigrationStage for hops that need your code. Prefer it for new work over relying on a single large mapping model, because each hop is small and testable. The types are documented under “Staged migrations” in Apple’s Core Data documentation.

Write a mapping model for value transformations

For an entity-level transformation, add a Mapping Model (File > New > Mapping Model), map each source entity, and subclass NSEntityMigrationPolicy for custom logic. Set the class name under “Custom Policy” in the mapping inspector.

final class PersonToContactPolicy: NSEntityMigrationPolicy {
  override func createDestinationInstances(
    forSource source: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager
  ) throws {
    let contact = NSEntityDescription.insertNewObject(
      forEntityName: "Contact", into: manager.destinationContext)
    contact.setValue(source.value(forKey: "firstName"), forKey: "givenName")
    manager.associate(sourceInstance: source, withDestinationInstance: contact, for: mapping)
  }
}

Test every hop against a fixture store

Commit one binary store for each released schema version, copy it to a temp URL, load it with the current model and the migration flags, and assert that load succeeds and the data survived. Generate fixtures by running the shipped version against a known dataset and copying its .sqlite, -wal, and -shm files. Run these in CI.

let container = NSPersistentContainer(name: "Model")
container.persistentStoreDescriptions.first!.url = tempURL
var loadError: Error?
container.loadPersistentStores { _, error in loadError = error }
XCTAssertNil(loadError)

Keep a path from every shipped version

Core Data can chain v1 to v3 through v2, but every hop needs an inferable or explicit mapping, so keep each one tested. Never skip a version that users may still run. If supporting a very old version is impractical, ship a recovery flow that exports the data before recreating the store.