Core Data Migration in Production: Lightweight vs Heavy-Weight Strategies for iOS Apps
Core Data migration strategy decides whether shipped user stores survive schema changes, especially when lightweight inference stops being enough.
Schema changes are inevitable. The data model evolves, the app ships, and the next version needs a new attribute, a renamed entity, or a restructured relationship. How that transition is handled determines whether the update goes smoothly or the app crashes on launch for a percentage of the install base.
Core Data migration falls into two categories: lightweight and heavy-weight (manual migration). Choosing the wrong one for the wrong situation is one of the more reliable sources of production incidents in data-heavy iOS apps.
This article covers when each strategy applies, how to implement both correctly, and what changes when the store is also syncing with CloudKit.
Context
Most iOS apps that use Core Data start simple — one or two entities, a handful of attributes, a single NSPersistentContainer setup. Lightweight migration handles the early changes without friction.
The problem surfaces later. The data model has diverged significantly from its original shape. The store holds real user data. The next migration is not a simple rename — it requires logic, conditional transforms, or splitting one entity into two.
At that point, teams often discover that their migration strategy was never actually a strategy. It was an assumption.
What Lightweight Migration Can and Cannot Do
Lightweight migration uses NSMigratePersistentStoresAutomatically and NSInferMappingModelAutomatically set to true on the NSPersistentStoreDescription. Core Data infers the mapping model at runtime by comparing the source and destination model versions.
This works for a well-defined set of changes:
- Adding an optional attribute
- Removing an attribute
- Renaming an entity or attribute (using the
renamingIdentifierin the model editor) - Adding or removing a relationship
- Changing a relationship from non-optional to optional
It does not work for:
- Splitting one entity into two
- Merging two entities into one
- Transforming attribute values (converting a stored string to a structured type, for example)
- Changing an attribute's type
- Any migration that requires reading existing values to compute new ones
Attempt lightweight migration on a change outside this list and Core Data throws at store load time. The app either crashes or presents blank state, depending on how the error is handled.
The Renaming Identifier
One detail that catches teams: renaming an entity or attribute without setting the renamingIdentifier breaks lightweight migration. Core Data sees a removed entity and a new entity — not a rename. Set the identifier in the model editor to the original name before shipping the new version.
Heavy-Weight Migration: Manual Mapping Models
When lightweight migration cannot infer the mapping, you write it yourself using NSMappingModel and, when logic is required, NSEntityMigrationPolicy subclasses.
Mapping Models
A mapping model (.xcmappingmodel) defines how source entities map to destination entities. Xcode generates a starting point via Editor > Create NSMappingModel. Attribute mappings and relationship mappings are then configured manually.
For attribute transforms that require no custom logic, expressions work directly in the mapping model. For anything conditional, subclass NSEntityMigrationPolicy.
NSEntityMigrationPolicy
class LegacyTransactionMigrationPolicy: NSEntityMigrationPolicy {
override func createDestinationInstances(
forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager
) throws {
let dInstance = NSEntityDescription.insertNewObject(
forEntityName: mapping.destinationEntityName!,
into: manager.destinationContext
)
// Copy simple attributes
dInstance.setValue(sInstance.value(forKey: "amount"), forKey: "amount")
// Transform: split a legacy "fullName" string into firstName + lastName
if let fullName = sInstance.value(forKey: "fullName") as? String {
let parts = fullName.split(separator: " ", maxSplits: 1)
dInstance.setValue(String(parts.first ?? ""), forKey: "firstName")
dInstance.setValue(parts.count > 1 ? String(parts[1]) : "", forKey: "lastName")
}
manager.associate(sourceInstance: sInstance, withDestinationInstance: dInstance, for: mapping)
}
}
The migration manager handles the store copy internally. The policy class handles per-object transformation.
Multi-Step Migration
A store at version 1 cannot jump directly to version 5. Core Data requires a migration path through each intermediate version: 1 → 2 → 3 → 4 → 5.
That means a mapping model for each consecutive version pair. If intermediate mapping models were never created, the solution is to implement NSMigrationManager manually and chain the migrations in sequence.
func migrateStore(at storeURL: URL) throws {
let migrationSteps: [(source: String, destination: String)] = [
("Model_v1", "Model_v2"),
("Model_v2", "Model_v3"),
("Model_v3", "Model_v4"),
("Model_v4", "Model_v5")
]
var currentURL = storeURL
for step in migrationSteps {
guard let sourceModel = managedObjectModel(named: step.source),
let destinationModel = managedObjectModel(named: step.destination),
let mappingModel = NSMappingModel(from: nil, forSourceModel: sourceModel, destinationModel: destinationModel) else {
continue // already at or past this version
}
let manager = NSMigrationManager(sourceModel: sourceModel, destinationModel: destinationModel)
let tempURL = storeURL.deletingLastPathComponent().appendingPathComponent("migration_temp.sqlite")
try manager.migrateStore(from: currentURL, sourceType: NSSQLiteStoreType,
options: nil, with: mappingModel,
toDestinationURL: tempURL, destinationType: NSSQLiteStoreType,
destinationOptions: nil)
if currentURL != storeURL {
try FileManager.default.removeItem(at: currentURL)
}
currentURL = tempURL
}
if currentURL != storeURL {
_ = try FileManager.default.replaceItemAt(storeURL, withItemAt: currentURL)
}
}
Run this before loading the persistent store. Trigger it before NSPersistentContainer.loadPersistentStores and the migration executes on a background thread.
Migration and CloudKit Sync
NSPersistentCloudKitContainer introduces additional constraints. CloudKit sync does not pause automatically during migration. The sync state in the cloudkit-sync.sqlite metadata store must remain consistent with the migrated schema.
Two hard rules:
First: Disable CloudKit sync during migration. Set cloudKitContainerOptions to nil on the store description before migration runs, then restore it after the migrated store loads successfully.
Second: Test migration against a store that has pending CloudKit sync operations queued. A clean local store migrates fine. A store with unsynced changes and a pending NSPersistentCloudKitContainer operation can produce entity conflicts after migration if the mapping model does not preserve the NSManagedObjectID relationships CloudKit uses internally.
The offline-first Core Data and CloudKit sync footguns article covers the broader class of sync-related failures in local-first architectures — several surface specifically during migration windows.
Performance on Large Stores
Heavy-weight migration on a 500MB store on an older device takes time. The migration manager reads every source object, transforms it, and writes it to a new store. On an iPhone 12 with a store that size, expect 30–90 seconds depending on transform complexity.
Strategies to reduce migration time:
- Batch processing: Override
createDestinationInstancesto process objects in batches and callsave()on the destination context every 500 objects — this reduces peak memory pressure. - Progress reporting:
NSMigrationManagerexposes amigrationProgressproperty (0.0 to 1.0). Poll it on a background thread and update a progress indicator on the main thread. - Deferred migration: For non-critical schema additions, add the attribute as optional and populate it lazily on first access rather than migrating all existing records upfront.
Testing Migration Correctly
The most common mistake: testing only on a fresh simulator install. Production stores carry months of accumulated data, including edge cases no test fixture covers.
A reliable migration test setup:
- Keep a copy of the SQLite store from each shipped version in the test bundle.
- Write a unit test that loads each historical store, runs migration to the current model, and verifies entity counts and spot-checked attribute values.
- Run these tests on every model change before shipping.
func testMigrationFromV2ToV5() throws {
let sourceURL = Bundle(for: type(of: self)).url(forResource: "store_v2", withExtension: "sqlite")!
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("test_migration.sqlite")
try FileManager.default.copyItem(at: sourceURL, to: tempURL)
try migrateStore(at: tempURL)
let container = NSPersistentContainer(name: "Model_v5")
// load and assert
}
This catches regressions before they reach the App Store.
SwiftData Migration in 2026
SwiftData, introduced in iOS 17, handles lightweight migration automatically through VersionedSchema and SchemaMigrationPlan. The API is cleaner than the NSMappingModel approach, but the underlying constraints are identical — automatic inference works for simple changes, complex transforms require a MigrationStage.custom stage with explicit code.
For new apps targeting iOS 17+, SwiftData's migration API is worth adopting. For existing apps with years of Core Data migration history, converting to SwiftData for migration alone is not worth the risk. The Core Data stack already in place is the right tool.
When to Audit Your Migration Strategy
If the app has shipped more than three model versions and the migration path is undocumented, that is a risk. The gap between "it worked in testing" and "it failed for 2% of installs" is usually an undocumented edge case somewhere in the historical migration chain.
The AI-Native App Architecture Audit at 3nsofts.com covers architecture, AI readiness, and App Store compliance — including data layer decisions like migration strategy, CloudKit sync configuration, and schema versioning. The audit delivers 12–20 prioritized findings in 5 business days, starting from 1,440 euros.
FAQs
What is the difference between lightweight and heavy-weight Core Data migration?
Lightweight migration lets Core Data infer the mapping model automatically at runtime. It handles simple changes: adding optional attributes, renaming entities (with renamingIdentifier set), and adding or removing relationships. Heavy-weight migration requires a manually authored NSMappingModel and, for logic-dependent transforms, an NSEntityMigrationPolicy subclass. Use heavy-weight when you need to transform attribute values, split entities, or handle any change the inference engine cannot resolve.
Does Core Data migration work automatically when using NSPersistentCloudKitContainer?
CloudKit sync does not pause during migration. Disable cloudKitContainerOptions before running migration and re-enable it after the migrated store loads. Skipping this step can produce sync conflicts when the migrated schema does not match the CloudKit record structure queued before migration ran.
How do I migrate a Core Data store across multiple versions?
Core Data requires a migration path through each consecutive version pair — a mapping model for each step. Implement NSMigrationManager manually, chain the migrations in sequence, and write each intermediate result to a temporary file before replacing the original store.
What happens if lightweight migration fails at runtime?
Core Data throws an NSError when it cannot load the persistent store. Without a fallback, the store fails to load and the app presents empty state or crashes, depending on error handling. Always handle the loadPersistentStores error explicitly and log the migration failure with enough context to diagnose the version mismatch.
How should I test Core Data migration before shipping?
Keep a copy of the SQLite store from each shipped version in the test bundle. Write unit tests that load each historical store, run migration to the current model, and verify entity counts and attribute values. Test on real device hardware for large stores — simulator I/O performance does not reflect production migration times.
Is SwiftData migration easier than Core Data migration?
SwiftData uses VersionedSchema and SchemaMigrationPlan to express migration steps with a cleaner Swift API. Lightweight changes are handled automatically. Complex transforms use MigrationStage.custom. The underlying constraints are identical to Core Data. For new apps targeting iOS 17+, SwiftData's migration API is worth using. For existing apps with a long Core Data migration history, the conversion cost outweighs the API improvement.
How long does heavy-weight migration take on a large store?
On an iPhone 12 with a 500MB store, expect 30–90 seconds depending on transform complexity. Reduce peak memory pressure by saving the destination context every 500 objects. Surface progress to the user via NSMigrationManager.migrationProgress, which reports 0.0 to 1.0 as migration runs.