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
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.
Course Deep Dive: This topic is covered in detail in Lesson 9.1: An introduction to Swift Concurrency and Core Data
Available Async APIs
Context perform
extension NSManagedObjectContext {
func perform<T>(
schedule: ScheduledTaskType = .immediate,
_ 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
Course Deep Dive: This topic is covered in detail in Lesson 9.2: Sendable and NSManageObjects
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 {
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:
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)
}
}
}Usage
// Main thread operations
@MainActor
func loadArticles() throws -> [Article] {
try CoreDataStore.shared.perform { context in
let request = Article.fetchRequest()
return try context.fetch(request)
}
}
// Background operations
func 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
- @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)
Note: Usually not needed. Consider simple pattern first.
Course Deep Dive: This topic is covered in detail in Lesson 9.3: Using a custom Actor executor for Core Data (advanced)
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
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
- 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?
}
> **Course Deep Dive**: This topic is covered in detail in [Lesson 9.4: Autogenerated Core Data Objects and Default MainActor Isolation Conflicts](https://www.swiftconcurrencycourse.com?utm_source=github&utm_medium=agent-skill&utm_campaign=lesson-reference)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 {
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
@concurrent
func 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
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
- @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
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 @concurrent for background operations
- 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
}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
For Core Data best practices, migration strategies, and advanced patterns: