Overview
Stay on Core Data when the app already ships a store, needs CloudKit sharing, fetched properties, derived attributes, or per-entity migration policies. Use swiftdata for new apps; the decision rules are in core-data-swiftdata-comparison, and schema changes are in core-data-migrations. Every NSManagedObject is confined to the queue of its context, and Swift 6 strict concurrency enforces that. Default MainActor isolation in new Xcode 26+ projects does not change it (swift-concurrency).
Own one container and configure the view context
Build one NSPersistentContainer at app start. Set automaticallyMergesChangesFromParent so background saves appear in the view context, and a merge policy, because the default NSErrorMergePolicy fails a save on any conflict. NSMergeByPropertyObjectTrumpMergePolicy lets in-memory changes win; use NSMergeByPropertyStoreTrumpMergePolicy when the server is the authority.
final class PersistenceController {
let container = NSPersistentContainer(name: "Model")
init(inMemory: Bool = false) {
let description = container.persistentStoreDescriptions.first!
if inMemory { description.url = URL(fileURLWithPath: "/dev/null") }
description.shouldAddStoreAsynchronously = true
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
func load() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
container.loadPersistentStores { _, error in
if let error { cont.resume(throwing: error) } else { cont.resume() }
}
}
}
}loadPersistentStores runs its completion synchronously unless shouldAddStoreAsynchronously is true, and a pending lightweight migration runs inside it, so a large store can freeze launch. Load asynchronously and gate the UI on completion. Do not fatalError on every load failure in production: separate an incompatible or corrupt store (NSPersistentStoreIncompatibleVersionHashError and similar, which need a recovery path) from a bug, and log the failure.
Inject container.viewContext through @Environment(\.managedObjectContext) rather than a global singleton, and back previews and tests with PersistenceController(inMemory: true). Hide the store behind a repository protocol if views should not know about NSFetchRequest.
Respect context confinement
Run imports, large fetches, and batch work on a background context, never on viewContext. Pass NSManagedObjectID, never the object, across contexts and re-fetch with existingObject(with:). A temporary ID (before the first save) must become permanent with obtainPermanentIDs(for:) before it crosses a boundary.
try await container.performBackgroundTask { context in
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
for dto in dtos { let item = Item(context: context); item.id = dto.id; item.title = dto.title }
try context.save()
}- Use
performBackgroundTaskfor one-shot work and a single reusednewBackgroundContext()for a long-lived sync engine. - Use the
asyncperform(iOS 15+) and avoidperformAndWait, which blocks the caller. - Set
mergePolicyon every background context; conflicts are normal in a concurrent app. - Use a child context (
parent = viewContext) as a scratch pad for an edit screen the user can cancel. Saving the child pushes changes to the parent only, so save the parent too to reach disk.
Make fetches cheap
Mark frequently filtered or sorted attributes “Indexed” in the model editor; an unindexed predicate scans the table, but each index costs write time and disk, so skip rarely queried attributes. Build predicates with %K and #keyPath so a typo fails to compile, set fetchLimit when you need the top N, and fetchBatchSize to page large result sets into memory.
request.predicate = NSPredicate(format: "%K == NO", #keyPath(Item.isArchived))
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(Item.createdAt), ascending: false)]
request.fetchLimit = 50Use @FetchRequest for a list driven by one fixed predicate and sort. When the predicate depends on user input, or the list is sectioned or highly dynamic, own an NSFetchedResultsController in an @Observable view model.
Merge batch operations back into the view context
NSBatchInsertRequest, NSBatchUpdateRequest, and NSBatchDeleteRequest write straight to SQLite and bypass change tracking. Request object IDs as the result and merge them, or the UI never learns of the change.
let request = NSBatchDeleteRequest(fetchRequest: Item.fetchRequest())
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
let changes = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [viewContext])Use batch inserts for imports of more than a few thousand rows. For a batch update, use .updatedObjectIDsResultType and NSUpdatedObjectsKey.