If you’ve been working with Core Data for a while, you’ll likely know that it’s unsafe to pass around managed objects between threads. Single-threaded apps are fine, but mostly because you’re using a managed object on the same thread constantly. For example, you can’t just simply use a managed object fetched from a viewContext in a different background context. This will certainly result in unexpected behavior.

This restriction doesn’t change when working with Swift Concurrency. You’ll still have to ensure that you’re accessing and modifying managed object contexts on their related managed object context. This is already complex to do correctly with just Core Data, even more so when you also have to consider isolation domains. Therefore, it’s important to isolated Core Data access or adopting something like the data access object (DAO) pattern, which provides an abstract interface to Core Data.

Can’t I make my managed objects conform to Sendable?

Before I show you how to use Data Access Objects, I want to address a question that I’m sure you’re asking: can’t I simply make my managed objects conform to Sendable?

The short answer is: no.

Well, you could use @unchecked Sendable, but we’ve learned that it’s not smart to do so. It won’t make your objects sendable; it will just hide the warnings.

Managed objects define their properties as mutable variables:

@NSManaged public var title: String

Conforming to Sendable requires you to remove mutable members or to isolate access. Neither is possible for an NSManagedObject type. This will quickly become visible when trying to add Sendable conformance:

In this example, I’ve added Sendable conformance while still compiling for Swift 5. The warning tells us that it will become an error in Swift 6 language mode—so it’s not a future-proof option.

How Data Access Objects can help out

A Data Access Object (DAO) provides a thread-safe instance of a managed object. You create such instances directly after fetching, and you use them to insert or update new data. The NSManagedObjectID is essential in this concept, as it serves as the connection between a DAO and its corresponding managed object entry.

Imagine the following managed object for an article:

@objc(Article)
public class Article: NSManagedObject, Identifiable {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<Article> {
        return NSFetchRequest<Article>(entityName: "Article")
    }

    @NSManaged public var title: String?
    @NSManaged public var timestamp: Date?
}

Its companion DAO object could look as follows:

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
    }
}

We can still make use of NSManagedObjectID, as it conforms to @unchecked Sendable in the standard library:

@available(macOS 10.4, *)
open class NSManagedObjectID : NSObject, NSCopying, @unchecked Sendable { // ...

This is correct, as it has always been safe to pass it around threads in Core Data.

The new ArticleDAO is ready to use and marked as Sendable. However, it’s not as simple as that. You’ll have to rewrite your app to make use of these new data access objects. This is not a simple operation as it requires you to rewrite all logic related to fetching and mutating. I won’t go into extreme detail in this lesson to help you solve that, as it would result in a dedicated Core Data course.

Working with Swift Concurrency without Data Access Objects

I fully understand it’s not always possible to migrate your project to Data Access Objects. That’s alright—projects can still perfectly work with Strict Concurrency and Swift 6. In fact, if you create a new project in Xcode with Core Data support, you’ll have a project that compiles without warnings on Swift 6.

Passing around NSManagedObjectID only

What’s already been important in Core Data counts for Swift Concurrency as well: you should only pass around NSManagedObjectID instances. As mentioned before, they’re thread-safe and marked as Sendable. As long as you keep yourself to this contract, you’ll not run into any concurrency-related exceptions. It’s recommended to apply proper Core Data debugging techniques while validating your implementation. You can use my article “Core Data Debugging in Xcode using launch arguments” for this.

Summary

Core Data’s managed objects cannot conform to Sendable. The only way to pass them around tasks is by using their NSManagedObjectID value. Ideally, you would convert your project to utilize so-called Data Access Objects to ensure working with a thread-safe copy.