SwiftQA Random

How do you manage Core Data concurrency?

Core Data’s concurrency model predates Swift concurrency entirely and has its own specific rules that don’t map cleanly onto actors/async-await.

The core rule: managed objects are confined to the context that created them

An NSManagedObjectContext and every NSManagedObject it owns must only be accessed on the specific queue/thread the context is confined to. Violating it is undefined behavior — in practice, intermittent crashes or silent data corruption.

1. The standard multi-context pattern

final class CoreDataStack {
    lazy var persistentContainer: NSPersistentContainer = { ... }()

    lazy var viewContext: NSManagedObjectContext = {
        let context = persistentContainer.viewContext
        context.automaticallyMergesChangesFromParent = true
        return context
    }()

    func newBackgroundContext() -> NSManagedObjectContext {
        persistentContainer.newBackgroundContext()
    }
}

viewContext for reading/display only; background context(s) for writes/imports.

2. perform/performAndWait — the actual concurrency-safety mechanism

backgroundContext.perform {
    let user = User(context: backgroundContext)
    user.name = "Alice"
    try? backgroundContext.save()
}

perform is asynchronous; performAndWait blocks — same deadlock risk as GCD’s sync/async applies.

3. Bridging Core Data’s queue-confinement model into async/await

extension NSManagedObjectContext {
    func performAsync<T>(_ block: @escaping () throws -> T) async throws -> T {
        try await withCheckedThrowingContinuation { continuation in
            perform {
                do { continuation.resume(returning: try block()) }
                catch { continuation.resume(throwing: error) }
            }
        }
    }
}

NSManagedObject genuinely isn’t Sendable — its correctness depends on queue confinement, not Swift’s isolation checker.

4. Never pass NSManagedObject instances across contexts/threads directly

// ✅ RIGHT — pass the objectID, re-fetch in the target context
func updateUser(objectID: NSManagedObjectID) async throws {
    let bgContext = coreDataStack.newBackgroundContext()
    try await bgContext.performAsync {
        guard let user = try bgContext.existingObject(with: objectID) as? User else { return }
        user.name = "Updated"
        try bgContext.save()
    }
}

This is the single most common Core Data concurrency bug.

5. Merging changes between contexts

automaticallyMergesChangesFromParent = true handles the common case; NSFetchedResultsController/@FetchRequest automatically observe viewContext.

6. Batch operations for genuinely large writes/deletes

NSBatchInsertRequest/NSBatchDeleteRequest/NSBatchUpdateRequest operate at the SQL level, bypassing the in-memory object graph — dramatically faster for bulk work, but don’t automatically merge into other contexts’ in-memory state.

7. Avoiding the “one context per operation” anti-pattern at scale

Creating a fresh background context for every single small operation works but can fragment merge/notification overhead. Many production apps settle on a small, deliberate number of long-lived contexts rather than a context-per-call-site pattern.

8. Testing Core Data code

Use an in-memory persistent store for fast, isolated unit tests, combined with the same perform-based discipline as production code.

Interview-ready summary

ConcernMechanism
Queue confinementEvery context access wrapped in perform/performAndWait
UI-thread contextviewContext, read/display only, never heavy writes
Writes/importsDedicated background context(s), off the main thread
Async/await bridgingContinuation-wrapped perform, not forcing Sendable/actor isolation
Cross-context object passingPass NSManagedObjectID, never the NSManagedObject itself
Propagating changesautomaticallyMergesChangesFromParent, NSFetchedResultsController/@FetchRequest
Bulk operationsNSBatchInsertRequest/NSBatchDeleteRequest, explicit merge afterward
TestingIn-memory store, same perform-based discipline as production

One-liner if pressed: “Core Data’s concurrency model predates Swift’s — every context is confined to a specific queue, and every access has to go through perform/performAndWait, so I keep a main-thread viewContext strictly for reading/display and dedicated background contexts for writes, bridge into async/await with a continuation wrapping perform rather than trying to force Core Data objects into Swift’s Sendable/actor model, and always pass NSManagedObjectID across contexts rather than the managed object itself, since that’s the single most common source of real production crashes in Core Data apps.”