Core Data and Swift Concurrency
Core Data and Swift Concurrency
Section titled “Core Data and Swift Concurrency”Thread-safe patterns for using Core Data with Swift Concurrency.
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>(_ 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 { try? 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:
final class CoreDataStore { let persistentContainer: NSPersistentContainer
var viewContext: NSManagedObjectContext { persistentContainer.viewContext }
init(persistentContainer: NSPersistentContainer) { self.persistentContainer = persistentContainer }
// View context operations (main thread) @MainActor func read<T>(_ block: (NSManagedObjectContext) throws -> T) rethrows -> T { try block(viewContext) }
// Background operations func performInBackground<T>( _ block: @Sendable @escaping (NSManagedObjectContext) throws -> T ) async rethrows -> T { let context = persistentContainer.newBackgroundContext() return try await context.perform { try block(context) } }}let store = CoreDataStore(persistentContainer: container)
// Main thread operations@MainActorfunc loadArticles() throws -> [Article] { try store.read { context in let request = Article.fetchRequest() return try context.fetch(request) }}
// Background operationsfunc deleteAll() async throws { try await store.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
- Dedicated entry points: Read/write APIs prevent accidental cross-context use
- 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 private let context: NSManagedObjectContext nonisolated let modelExecutor: NSManagedObjectContextExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor { modelExecutor.asUnownedSerialExecutor() }
private init() { persistentContainer = NSPersistentContainer(name: "MyApp") 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 { let context = container.newBackgroundContext() try await context.perform { guard let article = context.object(with: id) as? Article else { return } // Process article try context.save() }}Batch operations
Section titled “Batch operations”func deleteAllArticles() async throws { let context = container.newBackgroundContext() try await context.perform { 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
- Use background contexts - run heavy work off the main thread
- 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 background contexts for write-heavy work
- 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}Related References
Section titled “Related References”- See
threading.mdfor general Core Data threading patterns - See
batch-operations.mdfor async batch operation patterns - See
stack-setup.mdfor container setup with async/await - See
testing.mdfor testing async Core Data code
Further Learning
Section titled “Further Learning”For Core Data best practices, migration strategies, and advanced patterns: