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
| Concern | Mechanism |
|---|---|
| Queue confinement | Every context access wrapped in perform/performAndWait |
| UI-thread context | viewContext, read/display only, never heavy writes |
| Writes/imports | Dedicated background context(s), off the main thread |
| Async/await bridging | Continuation-wrapped perform, not forcing Sendable/actor isolation |
| Cross-context object passing | Pass NSManagedObjectID, never the NSManagedObject itself |
| Propagating changes | automaticallyMergesChangesFromParent, NSFetchedResultsController/@FetchRequest |
| Bulk operations | NSBatchInsertRequest/NSBatchDeleteRequest, explicit merge afterward |
| Testing | In-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.”