When the same concurrency-related code fails to compile in Xcode 16 but builds cleanly in Xcode 26, what’s your first instinct? Mine was that the compiler had gotten smarter — but reality turned out to be more nuanced. This post documents a recent debugging journey: starting from a test failure, tracing all the way down to the Core Data SDK interface, and ultimately discovering that the key change had nothing to do with the Swift compiler itself — it was how NSManagedObjectContext is imported into Swift that had changed.
The Problem
While updating and expanding test coverage for Persistent History TrackingKit 2, I ran into an unusual situation. To simulate a scenario where two different actors (such as a main app and an extension, or two components in separate processes) merge changes via Persistent History, I had two different isolation domains share a reference to the same NSManagedObjectContext instance:
let app2Handler = TestAppDataHandler( // actor domain
container: container,
context: app2Context,
viewName: "App2Handler"
)
let kit = PersistentHistoryTrackingKit( // current domain
container: container,
contexts: [app2Context],
currentAuthor: "App2",
allAuthors: ["App1", "App2"],
userDefaults: userDefaults,
uniqueString: uniqueString,
logLevel: 0,
autoStart: false
)
The complete test case (including the implementation of actor isolation and custom executors) can be found inthe actual test code.
In Swift 6 mode, this code compiles fine under Xcode 26.3 (Swift 6.2.4), but is immediately rejected by Xcode 16.4 (Swift 6.1.2) with a classic concurrency safety error:
sending 'app2Context' risks causing data races
My initial read was that Swift 6.1.2 saw a risk here because:
app2Contextwas being sent into an actor-related isolation domain- The current scope then continued accessing the same
app2Context - This created potential overlap between actor-isolated and local nonisolated uses
In practice, though, all concurrency operations involving context ran on its private queue, and TestAppDataHandler used a custom executor to pin actor execution to that context’s environment. From a runtime semantics standpoint, the code was theoretically safe.
My first instinct was that Swift 6.2’s implementation of Region-Based Isolation (SE-0414) had matured, allowing it to more accurately analyze non-Sendable value transfers across domains — letting through what Swift 6.1 had conservatively flagged as an error. I set out to verify this with Swift 6.2.
Did Swift 6.2’s Concurrency Analysis Actually Improve?
To test this, I tried to construct a minimal Swift reproduction that didn’t involve Core Data at all. The idea was simple:
- Create a plain non-Sendable reference type
- Pass it to an actor’s initializer
- Continue accessing it in the current scope
final class Token {
var value: Int = 0
}
private actor TokenActor {
init(token: Token) {}
}
func repro() {
let token = Token()
let actor = TokenActor(token: token)
_ = token.value
_ = actor
}
The result quickly disproved my initial hypothesis:
- Swift 6.1.2: error
- Swift 6.2.4: still an error
In other words, Swift 6.2 does not broadly allow the pattern of “access a non-Sendable value in the current scope after passing it into an actor domain.”
For a plain non-Sendable reference type, both compiler versions behave identically.
After experimenting with various Token type designs, I gradually narrowed my focus to NSManagedObjectContext itself — perhaps there was something special about this particular type.
Isolating the Problem with Real Code
Since the issue wasn’t with ordinary non-Sendable values, I took the opposite approach: stripping down the real test code piece by piece until I had the smallest skeleton that reliably reproduced the behavioral difference.
The final version came down to just a few lines:
import CoreData
private actor ReducedContextActor {
init(context: NSManagedObjectContext) {}
}
func repro() {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
let actor = ReducedContextActor(context: context)
context.name = "StillLocal"
_ = actor
}
Results:
- Swift 6.1.2: error
- Swift 6.2.4: passes
A clean, Core Data-specific reproduction.
Finding the Answer in Core Data’s Interface
Based on my prior understanding of Core Data, only NSManagedObjectID, NSPersistentContainer, and NSPersistentStoreCoordinatorhad been declared as Sendable. Had something else changed in Xcode 26’s Core Data?
I started comparing the Core Data header files and Swift interfaces bundled with Xcode 16 and Xcode 26 — and quickly found the decisive difference.
In Xcode 26.3’s NSManagedObjectContext.h, the class declaration is now preceded by:
NS_SWIFT_NONISOLATED NS_SWIFT_SENDABLE
@interface NSManagedObjectContext : NSObject <NSCoding, NSLocking>
These two annotations are absent from Xcode 16.4’s corresponding header.
In addition, Xcode 26.3’s CoreData.swiftinterface contains a number of newly added concurrency-related import signatures, such as:
convenience nonisolated public init(_ type: CoreData.NSManagedObjectContext.ConcurrencyType)
@preconcurrency nonisolated public func performAndWait<T>(...)
@preconcurrency nonisolated public func perform<T>(...)
nonisolated public func fetch<T>(...)
nonisolated public func count<T>(...)
The contrast with Xcode 16.4 is striking.
At this point, the answer was coming into focus: NSManagedObjectContext is now imported into Swift with stronger concurrency semantics in the new SDK.
What Are NS_SWIFT_SENDABLE and NS_SWIFT_NONISOLATED?
To avoid drawing conclusions purely from intuition, I traced things back to the macro definitions themselves.
Both are defined in Foundation’s NSObjCRuntime.h:
// Indicates that the thing it is applied to should be imported as 'Sendable' in Swift:
// * Type declarations are imported into Swift with a 'Sendable' conformance.
// * Block parameters are imported into Swift with an '@Sendable' function type. (Write it in the same place you would put 'NS_NOESCAPE'.)
// * 'id' parameters are imported into Swift as 'Sendable', not 'Any'.
// * Other object-type parameters are imported into Swift with an '& Sendable' requirement.
#define NS_SWIFT_SENDABLE __attribute__((swift_attr("@Sendable")))
// Indicates that a specific member of an 'NS_SWIFT_UI_ACTOR'-isolated type is "threadsafe" and should be callable from outside the main actor.
#define NS_SWIFT_NONISOLATED __attribute__((swift_attr("nonisolated")))
Just below those, there’s also:
// Indicates that a specific member of an 'NS_SWIFT_UI_ACTOR'-isolated type does its own data isolation management and does not participate in Swift concurrency checking.
#define NS_SWIFT_NONISOLATED_UNSAFE __attribute__((swift_attr("nonisolated(unsafe)")))
The comments are quite explicit about what these do:
NS_SWIFT_SENDABLE: Marks the annotated declaration to be imported into Swift asSendable. For type declarations, this means the Objective-C type carriesSendablesemantics when imported into Swift. For block parameters, the corresponding Swift closure is imported as@Sendable.NS_SWIFT_NONISOLATED: Marks the annotated member to be imported asnonisolated. The comment describes it as applying to individual members — but Apple has placed it on theNSManagedObjectContextclass declaration itself, meaning it produces a broader effect on how the entire type is imported.
The actual import results confirm it: NSManagedObjectContext is no longer treated as a plain non-Sendable Objective-C class in Xcode 26’s SDK. Like NSManagedObjectID, NSPersistentContainer, and NSPersistentStoreCoordinator, it is now a Sendable type.
Applying These Macros to Our Own Objective-C Classes
Even though the answer was essentially in hand, I wanted one final verification: would attaching NS_SWIFT_SENDABLEand NS_SWIFT_NONISOLATED to my own Objective-C class produce the same behavioral shift in Swift?
I created two local Objective-C modules:
Unannotated version
@interface ObjCPlainToken : NSObject
@property (nonatomic) NSInteger value;
@end
Annotated version
NS_SWIFT_NONISOLATED NS_SWIFT_SENDABLE
@interface ObjCAnnotatedToken : NSObject
@property (nonatomic) NSInteger value;
@end
The Swift test code used the same structure for both:
private actor TokenActor {
init(token: ObjC...Token) {}
}
func repro() {
let token = ObjC...Token()
let actor = TokenActor(token: token)
token.value = 1
_ = actor
}
The results were unambiguous:
- Unannotated version
- Swift 6.1.2: error
- Swift 6.2.4: error
- Annotated version
- Swift 6.1.2: passes
- Swift 6.2.4: passes
This is about as close to a smoking gun as it gets: these two macros genuinely alter the outcome of this diagnostic.
And NSManagedObjectContext in the new SDK happens to carry both of them. NSManagedObjectID, NSPersistentContainer, and NSPersistentStoreCoordinator had already adopted NS_SWIFT_SENDABLE as far back as Xcode 16, but none of them received NS_SWIFT_NONISOLATED the way NSManagedObjectContext now has.
Revisiting the Original Problem
Now we can state a more accurate conclusion.
My initial framing was:
- Swift 6.1 was overly conservative
- Swift 6.2 got smarter and let
NSManagedObjectContextthrough
The more accurate framing is:
- For ordinary non-Sendable reference types, Swift 6.1 and Swift 6.2 take the same position — both report an error.
NSManagedObjectContexthas been imported into Swift with stronger concurrency semantics in Xcode 26’s SDK, specifically viaNS_SWIFT_SENDABLEandNS_SWIFT_NONISOLATED.- As a result, it is no longer treated as a non-Sendable Objective-C type in Xcode 26 — it is now
Sendable.
What looked like an evolution of the Swift compiler was, in fact, a change in how the framework is imported.
Closing Thoughts
Although NSManagedObjectContext can now be passed across isolation domains, that doesn’t mean you should do so in production. Looking at SwiftData’s interface, ModelContext does have an @unchecked Sendable extension — but it’s marked unavailable, with an explicit note that contexts cannot be shared across concurrency contexts. Unless you have a genuine need to share context across isolation boundaries, the right approach is still to follow Core Data’s established concurrency guidelines.
Had I checked the NSManagedObjectContext declaration for changes right away, I might have found the answer in seconds. But because I jumped to blaming the compiler, I ended up tracing all the way down to the SDK headers. This turned out to be a useful reminder: when compiler behavior diverges between versions, it’s worth checking whether the framework’s import semantics have also shifted — not just the compiler itself. The answer is sometimes hiding in the place you didn’t think to look first.