iosNudge

How do you use Core Data safely with multiple threads?

HardPersistenceConcurrency 2 min read

TL;DR

NSManagedObjectContext and NSManagedObject are not thread-safe. Rules:

  1. Each context is tied to one queue. Touch it only inside perform { } (or on the main thread for the view context).
  2. Use viewContext for the UI. Use a background context for imports and heavy work.
  3. Never pass managed objects between contexts. Pass NSManagedObjectID and 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 = NSMergeByPropertyObjectTrumpMergePolicy

viewContext 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()
    }
}
  • perform runs the closure on the context's private queue.
  • The async version (iOS 15) lets you await it and return values or throw.
  • performAndWait blocks 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! Article

NSManagedObjectID 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:

PolicyWinner
NSErrorMergePolicy (default)Nobody, save throws
NSMergeByPropertyObjectTrumpMergePolicyIn-memory changes win
NSMergeByPropertyStoreTrumpMergePolicyValues already in the store win
NSOverwriteMergePolicyThe whole object in memory wins

Catch bugs early

Add this launch argument to your scheme:

-com.apple.CoreData.ConcurrencyDebug 1

Core 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? automaticallyMergesChangesFromParent is 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 @ModelActor for background work and pass PersistentIdentifier values between contexts.