Issue #1039
Actor isolation in Swift 6 is not binary. A type can be mostly isolated while selectively exposing some members to callers from any context. A function can accept any actor and run directly on its executor without being bound to it permanently. Two keywords control this: nonisolated opts a member out of its enclosing isolation, and isolated makes a function parameter a live entry point into an actor’s context. Understanding both turns isolation from a wall into a precision tool.
nonisolated
When you mark a member nonisolated, you’re telling the compiler it doesn’t need the actor’s protection: it contains no mutable isolated state, so any caller can reach it without await.
The most common use is exposing computed properties on an actor that only read immutable let constants.
actor BankAccount {
let accountNumber: String
var balance: Double
nonisolated var displayTitle: String {
"Account \(accountNumber)"
}
}
let account = BankAccount(accountNumber: "001", balance: 500)
print(account.displayTitle) // no await needed
let balance = await account.balance // await required
displayTitle only touches accountNumber, which is immutable. The actor’s executor is not involved, so there is nothing to wait for. balance is mutable and isolated, so it still requires await.
The rule is strict: a nonisolated member cannot read any isolated state on self. If your computed property touches a var, the compiler rejects it. You can only expose what the actor cannot change.
nonisolated also makes it possible to conform actors to synchronous protocols like Hashable or CustomStringConvertible without dragging isolated state into those requirements.
actor User: CustomStringConvertible {
let id: UUID
var name: String
nonisolated var description: String {
"User(\(id))"
}
}
nonisolated on delegate methods
The most important practical use of nonisolated is fixing a crash pattern that appears when an @MainActor-isolated class conforms to a protocol whose methods are called from an unknown thread.
When your class is @MainActor-isolated, every method inherits that isolation. If an SDK (like CLLocationManager or WKWebView) calls a delegate method from its own internal queue, the runtime asserts that the method is on the main actor. It is not, so the process crashes with _swift_task_checkIsolatedSwift.
@MainActor
class LocationManager: NSObject, CLLocationManagerDelegate {
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
// CLLocationManager calls this from its internal queue.
// @MainActor isolation is inherited and asserted at runtime.
// Crashes with _swift_task_checkIsolatedSwift.
updateMap(with: locations)
}
}
Mark the delegate method nonisolated to remove the isolation assertion. The method can then receive the call from any thread. Dispatch the actual UI work back to the main actor explicitly.
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
Task { @MainActor in
self.updateMap(with: locations)
}
}
This pattern applies to any SDK delegate that documents delivering callbacks off the main thread: AVAudioPlayerDelegate, NSTextStorageDelegate, URLSessionDataDelegate, and others.
nonisolated(unsafe)
Stored properties require different treatment. A computed property is safe to mark nonisolated as long as it reads only let constants. A stored var cannot be nonisolated: the compiler rejects it, because letting any thread read or write a mutable property without the actor’s serialization is a data race.
For global and static variables that are not actor-isolated, Swift 6 requires you to either protect them with a global actor or declare them as immutable Sendable constants. When neither option is possible, nonisolated(unsafe) tells the compiler to stop enforcing data-race checking for that variable. You take on full responsibility for thread safety.
// Immutable constant: no annotation needed
let sharedFormatter = DateFormatter()
// Actor-isolated: automatic protection
@MainActor var appState: AppState = .initial
// Manual synchronization: nonisolated(unsafe) suppresses the check
private let fontManagerLock = NSLock()
nonisolated(unsafe) var cachedFontNames: [String] = []
nonisolated(unsafe) is a migration escape hatch, not a general solution. Thread Sanitizer still catches runtime races even when the compiler does not. Use it for genuinely immutable singletons, C interop constants, or legacy globals while you migrate, not as a way to avoid fixing the underlying problem.
One current compiler bug worth knowing: nonisolated lazy var on an actor is incorrectly accepted by the compiler in Swift 6. It creates mutable shared state without actor protection. Do not use it.
isolated as a parameter keyword
The isolated keyword on a function parameter makes that function run directly on the actor passed in. Inside the function body, you have full synchronous access to that actor’s state without any await. At the call site, await is still required because the function must switch to the actor’s executor.
actor DataStore {
var username = "Anonymous"
var highScores: [Int] = []
func save() { }
}
func debugLog(store: isolated DataStore) {
print("Username: \(store.username)")
print("High scores: \(store.highScores)")
}
let store = DataStore()
await debugLog(store: store)
This differs from annotating the function with @MainActor. An @MainActor annotation is static: the function always runs on the main actor. An isolated parameter is dynamic: the function runs on whatever actor you pass in. This makes it useful for generic utilities that work across different actor types.
A particularly useful pattern is the transaction closure: a closure whose parameter is isolated to the actor, allowing multiple operations on that actor without multiple suspension points.
actor Database {
func beginTransaction() { }
func commitTransaction() { }
func rollbackTransaction() { }
func transaction<Result>(
_ work: @Sendable (_ db: isolated Database) throws -> Result
) rethrows -> Result {
beginTransaction()
do {
let result = try work(self)
commitTransaction()
return result
} catch {
rollbackTransaction()
throw error
}
}
}
try await database.transaction { db in
db.insert(record1)
db.insert(record2)
db.update(record3)
}
All three operations in the transaction closure run on the database actor as a single synchronized unit. Without isolated, each would require a separate await.
Inheriting caller isolation with #isolation
Library code sometimes needs to run on whatever actor calls it, without knowing in advance which actor that will be. The isolated (any Actor)? parameter type, combined with the #isolation default argument macro, handles this.
#isolation captures the caller’s static isolation and passes it automatically. The function then runs on that isolation without the caller having to specify it.
func withLogging<T>(
isolation: isolated (any Actor)? = #isolation,
operation: () async throws -> T
) async rethrows -> T {
print("Starting operation")
let result = try await operation()
print("Finished operation")
return result
}
@MainActor
func loadData() async throws {
try await withLogging { // automatically passes MainActor.shared
data = try await fetchData()
}
}
#isolation expands to different values depending on the calling context: nil from a non-isolated context, MainActor.shared from a @MainActor context, and self from inside an actor method. The function receives the right executor without any manual wiring.
This is also the mechanism for inheriting isolation in Task closures when you need to work with non-Sendable types. The closure can only inherit isolation if the isolated parameter is captured as a non-optional binding inside the task body.
func submitWork(
delegate: NonSendableDelegate,
isolation: isolated (any Actor)? = #isolation
) {
Task {
_ = isolation // this capture is required for isolation to be inherited
delegate.perform()
}
}
assumeIsolated and assertIsolated
Two methods exist for bridging legacy synchronous code into Swift’s isolation model.
MainActor.assertIsolated() is a debug-only check. It halts the process in debug builds if the current code is not running on the main actor. It compiles out of release builds entirely. Use it at the start of functions that document a main-actor precondition, as machine-verified documentation.
func updateLayout() {
MainActor.assertIsolated()
contentView.setNeedsLayout()
}
MainActor.assumeIsolated is different: it provides the compiler with an isolated scope, allowing you to access @MainActor state synchronously without await. It crashes at runtime in both debug and release builds if the assumption is wrong.
nonisolated func legacyCallback(data: Data) {
MainActor.assumeIsolated {
self.processResult(data) // access @MainActor state synchronously
}
}
Use assumeIsolated only when you have certainty from documentation or SDK contracts that the call arrives on the main thread, and there is no practical way to use async/await instead. It is appropriate when conforming to a pre-concurrency delegate protocol that guarantees main-thread delivery, such as older UIKit delegate methods. For anything where the thread is uncertain, use the nonisolated + Task { @MainActor in } pattern instead.
The two methods have different roles: assertIsolated is for debugging, assumeIsolated is for bridging. Do not use assumeIsolated as a substitute for await MainActor.run in contexts where you could use await.
isolated deinit
Before Swift 6.2, a deinit on an @MainActor-isolated class was always non-isolated, even though the rest of the class lived on the main actor. Accessing any isolated property in deinit was a compile error. The common workaround was to create a Task inside deinit to schedule cleanup, which is awkward because the task may outlive the object.
Swift 6.2 introduced isolated deinit, which runs the deinitializer on the class’s actor executor.
@MainActor
class Session {
var activeTask: Task<Void, Never>?
var isActive = false
isolated deinit {
activeTask?.cancel()
isActive = false // safe: runs on main actor
}
}
For an actor, isolated deinit runs on the actor itself. For a @MainActor class, it runs on the main actor. The runtime schedules a job to the appropriate executor when the object is released.
isolated deinit requires iOS 18.4 or macOS 15.4. As of mid-2026, several active compiler and runtime bugs affect this feature, including crashes in unit tests and certain simulator configurations. Treat it as a progressive enhancement guarded by availability checks, not as a general-purpose replacement for explicit cleanup methods.
nonisolated async in Swift 6.2
One of the most subtle changes in Swift 6.2 is how nonisolated async functions execute. Before Swift 6.2, a nonisolated async function always hopped off the caller’s actor to the global cooperative thread pool, even if there was no reason to. This caused spurious Sendable errors when passing non-Sendable values to async helpers.
Swift 6.2 introduced nonisolated(nonsending) to fix this. A function marked nonisolated(nonsending) runs on the caller’s actor instead of hopping away. It does not cross an isolation boundary, so its parameters do not need to be Sendable.
class NetworkClient {
nonisolated(nonsending)
func parseResponse(_ data: Data) async throws -> Response {
try JSONDecoder().decode(Response.self, from: data)
}
}
@MainActor
class ViewModel {
let client = NetworkClient()
var response: Response?
func load() async throws {
// parseResponse runs on the main actor, no Sendable requirement
response = try await client.parseResponse(rawData)
}
}
For CPU-heavy work that should explicitly leave the caller’s actor, @concurrent marks a function to always run on the global executor. This is the old default behavior, now made explicit.
@concurrent
nonisolated func resizeImages(_ images: [UIImage]) async -> [UIImage] {
images.map { resizeExpensively($0) }
}
When you enable the NonisolatedNonsendingByDefault upcoming feature flag for a target, all plain nonisolated async functions behave as nonisolated(nonsending) by default. Functions that need the old executor-hopping behavior must be annotated @concurrent explicitly. This flag is the migration path to what will become the default in a future Swift version.
The practical decision is: nonisolated(nonsending) for helpers that are logically part of the caller’s work and don’t need a thread switch, @concurrent for genuinely expensive parallel processing that should not block the caller’s actor.
Start the conversation