How do you use Core Data safely with multiple threads?
TL;DR
NSManagedObjectContext and NSManagedObject are not thread-safe.
Rules:
- Each context is tied to one queue. Touch it only inside
perform { }(or on the main thread for the view context). - Use
viewContextfor the UI. Use a background context for imports and heavy work. - Never pass managed objects between contexts. Pass
NSManagedObjectIDand fetch it again.
The setup
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
if let error { fatalError("Store failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicyviewContext runs on the main queue. automaticallyMergesChangesFromParent makes background saves show up in the UI.
Background work with perform
func importArticles(_ dtos: [ArticleDTO]) async throws {
let context = container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
try await context.perform {
for dto in dtos {
let article = Article(context: context)
article.id = dto.id
article.title = dto.title
}
try context.save()
}
}performruns the closure on the context's private queue.- The
asyncversion (iOS 15) lets youawaitit and return values or throw. performAndWaitblocks the caller. Use it rarely.
Or the one-off helper:
container.performBackgroundTask { context in
// runs on a new background context
}Passing objects between contexts
// Wrong: `article` belongs to the background context
let article = try await context.perform { try context.fetch(request).first! }
titleLabel.text = article.title // crash or wrong data
// Right: pass the ID
let objectID = try await context.perform {
try context.fetch(request).first!.objectID
}
let article = container.viewContext.object(with: objectID) as! ArticleNSManagedObjectID is thread-safe and Sendable. The object itself is not.
Batch operations
For thousands of rows, do not create objects one by one.
let insert = NSBatchInsertRequest(entity: Article.entity(), objects: dtos.map {
["id": $0.id, "title": $0.title]
})
try context.execute(insert)Batch requests skip the context and write to the store directly. They are fast, but the context does not know about them. Merge the changes yourself, or use persistent history tracking.
Merge conflicts
Two contexts can change the same object. The merge policy decides who wins:
| Policy | Winner |
|---|---|
NSErrorMergePolicy (default) | Nobody, save throws |
NSMergeByPropertyObjectTrumpMergePolicy | In-memory changes win |
NSMergeByPropertyStoreTrumpMergePolicy | Values already in the store win |
NSOverwriteMergePolicy | The whole object in memory wins |
Catch bugs early
Add this launch argument to your scheme:
-com.apple.CoreData.ConcurrencyDebug 1Core Data then crashes right away when you touch a context on the wrong queue. Turn it on in debug builds.
Swift 6 notes
NSManagedObject is not Sendable. The compiler warns when you send one across actors.
That is good: it enforces rule 3. Return value types (DTOs) or object IDs from perform.
Common follow-up questions
- Child context vs sibling background context? A child context saves into its parent, not to disk. Sibling contexts from
newBackgroundContext()save directly to the store and are simpler today. - Why does my UI not update after a background save?
automaticallyMergesChangesFromParentis off, or you used a batch request. - What is a fault? A placeholder object. Its data loads when you first read a property.
- How does SwiftData handle this? Use
@ModelActorfor background work and passPersistentIdentifiervalues between contexts.