How to use closure with Core Data in Swift 6

Issue #1036

Swift 6 makes concurrency safer by enforcing actor isolation at compile time, and sometimes at runtime. One of the subtler rules is that closures automatically inherit the actor isolation of the context where they are defined, not where they are called. This rule is mostly invisible until you pass a closure across a thread boundary and the app crashes.

The pattern shows up repeatedly in CoreData code, where a background context queue calls a closure that was created on the main actor.

What Isolation inheritance means

When you define a closure inside an @MainActor function or class, Swift 6 treats that closure as @MainActor-isolated by default, provided the closure is not explicitly marked @Sendable. The closure carries an implicit assertion: “I must run on the main actor.” If something calls it from a different thread, the Swift runtime fires a precondition failure.

@MainActor
final class ViewModel: ObservableObject {
    func deleteItems(_ items: [Item]) {
        loader.delete(items: items) {
            // This closure is @MainActor-isolated, it was defined here
        }
    }
}

If delete(items:block:) calls the block from a background queue, you get a runtime crash: dispatch_assert_queue_fail.

The CoreData crash

CoreData’s private-queue contexts run work on their own serial background queue. The standard pattern is context.perform { }, which always executes the block on that queue. The problem arises when the block you pass in inherited @MainActor isolation from where it was created.

// block is @escaping () -> Void, no @Sendable
func performAndSave(_ block: @escaping () -> Void) {
    perform {
        block()     // called on private queue
        save()
    }
}

When performAndSave is called from an @MainActor method, the closure inherits @MainActor isolation. Inside perform, the block runs on the background queue. Swift’s runtime checks whether the caller is on the main actor, it is not, and crashes.

The fix is to mark the block parameter @Sendable. A @Sendable closure is explicitly non-isolated: it cannot carry actor isolation from its defining context, and must be callable from any thread.

func performAndSave(_ block: @escaping @Sendable (NSManagedObjectContext) -> Void) {
    perform { [weak self] in
        guard let self else { return }
        block(self)
        save()
    }
}

Passing the context as a parameter is a bonus: callers no longer need to capture NSManagedObjectContext from outside the closure, and the [weak context] retain-cycle workaround disappears. The @Sendable constraint at the parameter level prevents actor isolation from leaking into the block.

Background delegate callbacks

The same crash happens with delegate methods. NSFetchedResultsControllerDelegate fires controllerDidChangeContent on the context’s private queue. If the conforming type is @MainActor, Swift 6 inserts an isolation check at the entry of every @MainActor method. The delegate method runs on the wrong thread and crashes.

Mark the method nonisolated, convert managed objects to value types on the background queue, then hop to the main actor only for the UI write:

@MainActor
final class DataLoader: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
    @Published var items: [Item] = []

    nonisolated func controllerDidChangeContent(
        _ controller: NSFetchedResultsController<NSFetchRequestResult>
    ) {
        guard let managed = controller.fetchedObjects as? [NSManagedObject] else { return }
        controller.managedObjectContext.perform { [weak self] in
            // Convert managed objects to Sendable value types on the background queue
            let values = managed.compactMap { mapToValueType($0) }
            Task { @MainActor [weak self] in
                self?.items = values
            }
        }
    }
}

Convert on the background queue, publish on the main actor. This pattern applies to any framework callback that fires from a non-main thread.

Conforming NSManagedObject subclasses to Sendable

Once block parameters are @Sendable, the compiler may flag captured managed objects as non-Sendable. NSManagedObject is not Sendable by default because it is bound to its context’s queue, but the subclasses you define are auto-generated and have no explicit conformance.

The practical fix is to declare @unchecked Sendable on your managed object subclass. This tells the compiler you accept responsibility for thread safety, which is already enforced by context.perform at every call site.

// Declare alongside your value-type wrapper, not in generated files
extension CDItem: @unchecked Sendable {}

For types you do not own, such as NSFetchRequest or NSPredicate, add @preconcurrency to the import instead:

@preconcurrency import CoreData

This suppresses Sendable warnings from Apple’s own types that have not yet been fully annotated for Swift 6.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation