Core Data and Swift Concurrency
Core Data and Swift Concurrency
Section titled “Core Data and Swift Concurrency”Use this when:
- You need to use Core Data with async/await or actors.
NSManagedObjectinstances are crossing context or actor boundaries.- You are resolving default
@MainActorisolation conflicts with generated NSManagedObject subclasses.
Skip this file if:
- The issue is general actor isolation, not Core Data specific. Use
actors.md. - You need general Sendable guidance. Use
sendable.md.
Jump to:
- Core Principles
- Data Access Objects (DAO) Pattern
- Working Without DAOs (NSManagedObjectID)
- Bridging Closures to Async
- Custom Actor Executor (Advanced)
- Default MainActor Isolation
- SwiftUI Integration
- Common Mistakes
Core Principles
Section titled “Core Principles”Thread safety still matters
Section titled “Thread safety still matters”Core Data’s thread safety rules don’t change with Swift Concurrency:
- Can’t pass
NSManagedObjectbetween threads - Must access objects on their context’s thread
NSManagedObjectIDis thread-safe (can pass around)
NSManagedObject cannot be Sendable
Section titled “NSManagedObject cannot be Sendable”@objc(Article)public class Article: NSManagedObject { @NSManaged public var title: String // ❌ Mutable, can't be Sendable}Don’t use @unchecked Sendable - hides warnings without fixing safety.
Available Async APIs
Section titled “Available Async APIs”Context perform
Section titled “Context perform”extension NSManagedObjectContext { func perform<T>( schedule: ScheduledTaskType = .immediate, _ block: @escaping () throws -> T ) async rethrows -> T}What’s missing
Section titled “What’s missing”No async alternative for:
func loadPersistentStores( completionHandler: @escaping (NSPersistentStoreDescription, Error?) -> Void)Must bridge manually (see below).
Data Access Objects (DAO)
Section titled “Data Access Objects (DAO)”Thread-safe value types representing managed objects.
Pattern
Section titled “Pattern”// Managed object (not Sendable)@objc(Article)public class Article: NSManagedObject { @NSManaged public var title: String? @NSManaged public var timestamp: Date?}
// DAO (Sendable)struct ArticleDAO: Sendable, Identifiable { let id: NSManagedObjectID let title: String let timestamp: Date
init?(managedObject: Article) { guard let title = managedObject.title, let timestamp = managedObject.timestamp else { return nil } self.id = managedObject.objectID self.title = title self.timestamp = timestamp }}Benefits
Section titled “Benefits”- Sendable: Safe to pass across isolation domains
- Immutable: No accidental mutations
- Clear API: Explicit data transfer
Drawbacks
Section titled “Drawbacks”- Requires rewrite: All fetch/mutation logic
- Boilerplate: DAO for each entity
- Complexity: Additional layer of abstraction
Working Without DAOs
Section titled “Working Without DAOs”Pass only NSManagedObjectID between contexts.
Basic pattern
Section titled “Basic pattern”@MainActorfunc fetchArticle(id: NSManagedObjectID) -> Article? { viewContext.object(with: id) as? Article}
func processInBackground(articleID: NSManagedObjectID) async throws { let backgroundContext = container.newBackgroundContext() try await backgroundContext.perform { guard let article = backgroundContext.object(with: articleID) as? Article else { return } // Process article try backgroundContext.save() }}NSManagedObjectID is Sendable
Section titled “NSManagedObjectID is Sendable”// Safe to pass between taskslet articleID = article.objectID
Task { await processInBackground(articleID: articleID)}Bridging Closures to Async
Section titled “Bridging Closures to Async”Load persistent stores
Section titled “Load persistent stores”extension NSPersistentContainer { func loadPersistentStores() async throws { try await withCheckedThrowingContinuation { continuation in self.loadPersistentStores { description, error in if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: ()) } } } }}
// Usagetry await container.loadPersistentStores()Simple CoreDataStore Pattern
Section titled “Simple CoreDataStore Pattern”Enforce isolation at API level:
nonisolated struct CoreDataStore { static let shared = CoreDataStore()
let persistentContainer: NSPersistentContainer private var viewContext: NSManagedObjectContext { persistentContainer.viewContext }
private init() { persistentContainer = NSPersistentContainer(name: "MyApp") persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
Task { [persistentContainer] in try? await persistentContainer.loadPersistentStores() } }
// View context operations (main thread) @MainActor func perform(_ block: (NSManagedObjectContext) throws -> Void) rethrows { try block(viewContext) }
// Background operations @concurrent func performInBackground<T>( _ block: @escaping (NSManagedObjectContext) throws -> T ) async rethrows -> T { let context = persistentContainer.newBackgroundContext() return try await context.perform { try block(context) } }}// Main thread operations@MainActorfunc loadArticles() throws -> [Article] { try CoreDataStore.shared.perform { context in let request = Article.fetchRequest() return try context.fetch(request) }}
// Background operationsfunc deleteAll() async throws { try await CoreDataStore.shared.performInBackground { context in let request = Article.fetchRequest() let articles = try context.fetch(request) articles.forEach { context.delete($0) } try context.save() }}Why this pattern works
Section titled “Why this pattern works”- @MainActor: Enforces view context on main thread
- @concurrent: Forces background execution
- Compile-time safety: Wrong isolation = error
- Simple: No custom executors needed
Custom Actor Executor (Advanced)
Section titled “Custom Actor Executor (Advanced)”Note: Usually not needed. Consider simple pattern first.
Implementation
Section titled “Implementation”final class NSManagedObjectContextExecutor: @unchecked Sendable, SerialExecutor { private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) { self.context = context }
func enqueue(_ job: consuming ExecutorJob) { let unownedJob = UnownedJob(job) let executor = asUnownedSerialExecutor()
context.perform { unownedJob.runSynchronously(on: executor) } }
func asUnownedSerialExecutor() -> UnownedSerialExecutor { UnownedSerialExecutor(ordinary: self) }}Actor usage
Section titled “Actor usage”actor CoreDataStore { let persistentContainer: NSPersistentContainer nonisolated let modelExecutor: NSManagedObjectContextExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor { modelExecutor.asUnownedSerialExecutor() }
private init() { persistentContainer = NSPersistentContainer(name: "MyApp") let context = persistentContainer.newBackgroundContext() modelExecutor = NSManagedObjectContextExecutor(context: context) }
func deleteAll<T: NSManagedObject>( using request: NSFetchRequest<T> ) throws { let objects = try context.fetch(request) objects.forEach { context.delete($0) } try context.save() }}Drawbacks
Section titled “Drawbacks”- Hidden complexity: Executor details obscure Core Data
- Forces concurrency: Even for main thread operations
- Not simpler: More code than
perform { } - Error prone: Easy to use wrong context
Recommendation: Use simple pattern instead.
Default MainActor Isolation
Section titled “Default MainActor Isolation”Problem with auto-generated code
Section titled “Problem with auto-generated code”When default isolation set to @MainActor, auto-generated managed objects conflict:
// Auto-generated (can't modify)class Article: NSManagedObject { // Inherits @MainActor, conflicts with NSManagedObject}Error: Main actor-isolated initializer has different actor isolation from nonisolated overridden declaration
Solution: Manual code generation
Section titled “Solution: Manual code generation”- Set entity to “Manual/None” code generation
- Generate class definitions
- Mark as
nonisolated:
nonisolated class Article: NSManagedObject { @NSManaged public var title: String? @NSManaged public var timestamp: Date?}Benefit: Full control over isolation.
Common Patterns
Section titled “Common Patterns”Fetch on main thread
Section titled “Fetch on main thread”@MainActorfunc fetchArticles() throws -> [Article] { let request = Article.fetchRequest() return try viewContext.fetch(request)}Background save
Section titled “Background save”func saveInBackground() async throws { let context = container.newBackgroundContext() try await context.perform { let article = Article(context: context) article.title = "New Article" try context.save() }}Pass ID, fetch in context
Section titled “Pass ID, fetch in context”@MainActorfunc displayArticle(id: NSManagedObjectID) { guard let article = viewContext.object(with: id) as? Article else { return } // Use article}
func processArticle(id: NSManagedObjectID) async throws { try await CoreDataStore.shared.performInBackground { context in guard let article = context.object(with: id) as? Article else { return } // Process article try context.save() }}Batch operations
Section titled “Batch operations”@concurrentfunc deleteAllArticles() async throws { try await CoreDataStore.shared.performInBackground { context in let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Article") let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) }}SwiftUI Integration
Section titled “SwiftUI Integration”Environment injection
Section titled “Environment injection”@mainstruct MyApp: App { let persistentContainer = NSPersistentContainer(name: "MyApp")
var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistentContainer.viewContext) } }}View usage
Section titled “View usage”struct ContentView: View { @Environment(\.managedObjectContext) private var viewContext @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \Article.timestamp, ascending: true)] ) private var articles: FetchedResults<Article>
var body: some View { List(articles) { article in Text(article.title ?? "") } }}Best Practices
Section titled “Best Practices”- Pass NSManagedObjectID only - never managed objects
- Use perform { } - don’t access context directly
- @MainActor for view context - enforce main thread
- @concurrent for background - force background execution
- Manual code generation - control isolation
- Keep it simple - avoid custom executors unless needed
- Enable Core Data debugging - catch thread violations
- Merge changes automatically -
automaticallyMergesChangesFromParent = true - Use background contexts - for heavy operations
- Test with Thread Sanitizer - catch violations early
Debugging
Section titled “Debugging”Enable Core Data concurrency debugging
Section titled “Enable Core Data concurrency debugging”// Launch argument-com.apple.CoreData.ConcurrencyDebug 1Crashes immediately on thread violations.
Thread Sanitizer
Section titled “Thread Sanitizer”Enable in scheme settings to catch data races.
Assertions
Section titled “Assertions”@MainActorfunc fetchArticles() -> [Article] { assert(Thread.isMainThread) // Fetch from viewContext}Decision Tree
Section titled “Decision Tree”Need to access Core Data?├─ UI/View context?│ └─ Use @MainActor + viewContext│├─ Background operation?│ ├─ Quick operation? → perform { } on background context│ └─ Batch operation? → NSBatchDeleteRequest/NSBatchUpdateRequest│├─ Pass between contexts?│ └─ Use NSManagedObjectID only│└─ Need Sendable type? ├─ Can refactor? → Use DAO pattern └─ Can't refactor? → Pass NSManagedObjectIDMigration Strategy
Section titled “Migration Strategy”For existing projects
Section titled “For existing projects”- Enable manual code generation for all entities
- Mark entities as nonisolated if using default @MainActor
- Wrap Core Data access in CoreDataStore
- Use @MainActor for view context operations
- Use @concurrent for background operations
- Pass NSManagedObjectID between contexts
- Test with debugging enabled
For new projects
Section titled “For new projects”- Start with simple pattern (CoreDataStore)
- Manual code generation from the start
- Consider DAOs if heavy cross-context usage
- Enable strict concurrency early
Common Mistakes
Section titled “Common Mistakes”❌ Passing managed objects
Section titled “❌ Passing managed objects”func process(article: Article) async { // ❌ Article not Sendable}❌ Accessing context from wrong thread
Section titled “❌ Accessing context from wrong thread”func background() async { let articles = viewContext.fetch(request) // ❌ Not on main thread}❌ Using @unchecked Sendable
Section titled “❌ Using @unchecked Sendable”extension Article: @unchecked Sendable {} // ❌ Doesn't make it safe❌ Not using perform
Section titled “❌ Not using perform”func save() async { backgroundContext.save() // ❌ Not on context's thread}Common Mistakes Agents Make
Section titled “Common Mistakes Agents Make”- Passing
NSManagedObjectinstances across actors: Always transferNSManagedObjectIDor a Sendable value snapshot instead. - Using
@unchecked SendableonNSManagedObject: This does not make it thread-safe. The object is still bound to its context’s queue. - Skipping
perform { }: All background context access must go throughperformorperformAndWait. - Accessing
viewContextfrom a background task: The view context belongs to the main actor; access it only from@MainActor-isolated code.
Further Learning
Section titled “Further Learning”For Core Data best practices, migration strategies, and advanced patterns: