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. Swift 6.2’s approachable concurrency (default MainActor isolation in new Xcode 26+ projects) does not change this: viewContext work stays main-actor-safe by default, but a background context’s perform closures and any Task.detached work must still cross an explicit isolation boundary, either through nonisolated or by staying inside context.perform. 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.