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 reference

Parent-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.