Overview
Core Data context concurrency rules predate Swift concurrency, but Swift 6 strict concurrency checking enforces them at compile time. Every NSManagedObject is confined to the queue of the context that created it. Crossing that boundary silently produced corruption before; in Swift 6 it produces a compiler error or a runtime assertion. This page covers the right patterns for background imports, view context reads, and the parent-child context model. For the stack setup that underpins these patterns, see core-data-stack; for fetch requests that run on these contexts, see core-data-fetch-requests.
Confine every NSManagedObject to its context’s queue
Never pass an NSManagedObject across context boundaries. Pass NSManagedObjectID instead and re-fetch on the receiving context.
// Wrong: passing the object across contexts
let item = Item(context: viewContext)
Task.detached {
backgroundContext.save() // item is from viewContext; this is undefined behavior
}
// Right: pass the ID
let objectID = item.objectID
Task.detached {
let backgroundItem = try backgroundContext.existingObject(with: objectID) as? Item
backgroundItem?.process()
try backgroundContext.save()
}NSManagedObjectID is thread-safe and stable across saves (for non-temporary IDs). Temporary IDs (before first save) can be made permanent with obtainPermanentIDs(for:) before crossing boundaries.
Use perform for async context work, performAndWait only when blocking is unavoidable
Both perform and performAndWait execute a closure on the context’s private queue. perform returns immediately; performAndWait blocks the caller until the closure completes. Prefer perform.
func importItems(_ dtos: [ItemDTO], context: NSManagedObjectContext) async throws {
try await context.perform {
for dto in dtos {
let item = Item(context: context)
item.id = dto.id
item.title = dto.title
}
try context.save()
}
}NSManagedObjectContext.perform gained an async/await signature in iOS 15. Use it; the older callback-based perform { } requires manual error propagation and is harder to read.
Use performBackgroundTask for one-shot background work
NSPersistentContainer.performBackgroundTask creates a fresh private-queue context, executes the closure, and disposes the context. It is the right tool for self-contained imports that do not need a persistent background context.
func purgeArchived() async throws {
try await container.performBackgroundTask { context in
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
let request = NSFetchRequest<Item>(entityName: "Item")
request.predicate = NSPredicate(format: "isArchived == YES")
let archived = try context.fetch(request)
archived.forEach { context.delete($0) }
try context.save()
}
}Always set mergePolicy on the background context. The default NSErrorMergePolicy throws on any conflict; in a concurrent app, conflicts are normal.
Prefer newBackgroundContext() for long-lived background operations
For a sync engine or import queue that runs repeatedly, create one context with container.newBackgroundContext() and reuse it. Recreating a context on every operation discards the row cache and is slower.
final class SyncEngine {
private let context: NSManagedObjectContext
init(container: NSPersistentContainer) {
context = container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.automaticallyMergesChangesFromParent = true
}
func sync(records: [ServerRecord]) async throws {
try await context.perform {
// upsert logic
try self.context.save()
}
}
}Use parent-child contexts for scratch-pad editing
A child context shares the parent’s object graph. Saving the child pushes changes to the parent but not to disk; discarding the child discards unsaved changes cleanly. This is ideal for edit screens where the user can cancel.
func editContext(parent: NSManagedObjectContext) -> NSManagedObjectContext {
let child = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
child.parent = parent
child.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return child
}
// On save: save the child, then save the parent to flush to disk
try childContext.save()
try viewContext.save()
// On cancel: discard the child context referenceParent-child contexts avoid storing “draft” flags on model objects and keep the undo scope narrow.
Avoid the main actor for large fetches and imports
A large NSFetchRequest on viewContext blocks the main thread for the duration of the SQL query. Move it to a background context and publish the result through @Observable or Combine.
@Observable final class ItemListModel {
var items: [Item] = []
func load(container: NSPersistentContainer) async {
let ids: [NSManagedObjectID] = await container.performBackgroundTask { ctx in
let request = NSFetchRequest<Item>(entityName: "Item")
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
return (try? ctx.fetch(request))?.map(\.objectID) ?? []
}
items = ids.compactMap { try? container.viewContext.existingObject(with: $0) as? Item }
}
}For most list screens, @FetchRequest is simpler; use the manual pattern only when the query is expensive enough to warrant profiling. See core-data-fetch-requests for @FetchRequest guidance.