Core Data and Swift Concurrency
Thread-safe patterns for using Core Data with Swift Concurrency.
Core Principles
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
@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
Context perform
extension NSManagedObjectContext {
func perform<T>(_ block: @escaping () throws -> T) async rethrows -> T
}What's missing
No async alternative for:
func loadPersistentStores(
completionHandler: @escaping (NSPersistentStoreDescription, Error?) -> Void
)Must bridge manually (see below).
Data Access Objects (DAO)
Thread-safe value types representing managed objects.
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
- Sendable: Safe to pass across isolation domains
- Immutable: No accidental mutations
- Clear API: Explicit data transfer
Drawbacks
- Requires rewrite: All fetch/mutation logic
- Boilerplate: DAO for each entity
- Complexity: Additional layer of abstraction
Working Without DAOs
Pass only NSManagedObjectID between contexts.
Basic pattern
@MainActor
func 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
// Safe to pass between tasks
let articleID = article.objectID
Task {
try? await processInBackground(articleID: articleID)
}Bridging Closures to Async
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: ())
}
}
}
}
}
// Usage
try await container.loadPersistentStores()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)
}
}
}Usage
let store = CoreDataStore(persistentContainer: container)
// Main thread operations
@MainActor
func loadArticles() throws -> [Article] {
try store.read { context in
let request = Article.fetchRequest()
return try context.fetch(request)
}
}
// Background operations
func 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
- @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)
Note: Usually not needed. Consider simple pattern first.
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
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
- 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
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
- 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
Fetch on main thread
@MainActor
func fetchArticles() throws -> [Article] {
let request = Article.fetchRequest()
return try viewContext.fetch(request)
}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
@MainActor
func 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
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
Environment injection
@main
struct MyApp: App {
let persistentContainer = NSPersistentContainer(name: "MyApp")
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistentContainer.viewContext)
}
}
}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
- 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
Enable Core Data concurrency debugging
// Launch argument
-com.apple.CoreData.ConcurrencyDebug 1Crashes immediately on thread violations.
Thread Sanitizer
Enable in scheme settings to catch data races.
Assertions
@MainActor
func fetchArticles() -> [Article] {
assert(Thread.isMainThread)
// Fetch from viewContext
}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
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
- Start with simple pattern (CoreDataStore)
- Manual code generation from the start
- Consider DAOs if heavy cross-context usage
- Enable strict concurrency early
Common Mistakes
❌ Passing managed objects
func process(article: Article) async {
// ❌ Article not Sendable
}❌ Accessing context from wrong thread
func background() async {
let articles = viewContext.fetch(request) // ❌ Not on main thread
}❌ Using @unchecked Sendable
extension Article: @unchecked Sendable {} // ❌ Doesn't make it safe❌ Not using perform
func save() async {
backgroundContext.save() // ❌ Not on context's thread
}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
For Core Data best practices, migration strategies, and advanced patterns: