---
title: "Core Data Concurrency: perform, Context Isolation, and Parent-Child Contexts"
slug: "core-data-concurrency"
category: "ios"
tags: ["ios", "core-data", "swift", "concurrency", "background-context", "swift-concurrency", "threading"]
status: "stable"
last_updated: 2026-08-14
summary: "Safe Core Data concurrency in Swift 6: perform/performAndWait, context confinement, passing object IDs across contexts, and parent-child context patterns."
related: ["[[ios/core-data]]", "[[ios/core-data-stack]]", "[[ios/core-data-fetch-requests]]", "[[ios/core-data-migrations]]", "[[ios/swiftui]]", "[[coding/swift]]", "[[ios/core-data-swiftdata-comparison]]"]
---

## 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 [[ios/core-data-stack]]; for fetch requests that run on these contexts, see [[ios/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.

```swift
// 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`.

```swift
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.

```swift
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.

```swift
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.

```swift
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.

```swift
@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 [[ios/core-data-fetch-requests]] for `@FetchRequest` guidance.

## Related

- [[ios/core-data]]
- [[ios/core-data-stack]]
- [[ios/core-data-fetch-requests]]
- [[ios/core-data-migrations]]
- [[ios/core-data-swiftdata-comparison]]
- [[ios/swiftui]]
- [[coding/swift]]
