It proves at compile time that no mutable state can be reached from two isolation domains at once: every value crossing a boundary is Sendable or provably handed off. The compiler version decides which checks exist; each target's language mode decides whether they are errors.
What is checked
Sendable at boundaries: arguments and results of cross-actor calls, and captures of Task and @Sendable closures.
Actor isolation: isolated state is touched only from inside its actor, or through an awaited call.
Global mutable state: a global or static var must be isolated, immutable or guarded (SE-0412).
Region isolation: a non-Sendable value may cross only when nothing else can still reach it (SE-0414).
Not checked: logic races such as check-then-act across an await, deadlocks and ordering. Swift 6 removes data races, not race conditions.
Compiler vs language mode
Xcode 16+ ships the Swift 6 compiler, but every target picks its own mode: SWIFT_VERSION in Xcode, .swiftLanguageMode(_:) in SwiftPM. A package with swift-tools-version: 6.0 defaults to 6.
Swift 5 mode with -strict-concurrency=complete runs the same analysis but reports warnings. Swift 6 mode makes them errors and adds runtime isolation checks (SE-0423) where unchecked code calls in.
Modes mix freely across modules, which is what makes target-by-target migration possible.
iOSSenior
Swift 6 Strict Concurrency
Isolation domains, Sendable, global actors, region isolation, sending, Swift 5 to 6 migration
Questions closed
0 / 19
- 0Review
- 1Asked often
Questions
An isolation domain is a scope whose mutable state only one task touches at a time: each actor instance is one, each global actor such as @MainActor is one, and nonisolated code belongs to none. A value crosses between them only if it is Sendable or handed off with nothing left behind.
The domains
An actor instance: its stored state and isolated methods. Two instances of the same actor are two domains.
A global actor: @MainActor or your own @globalActor. Every declaration marked with it shares that one domain.
Nonisolated code: free functions, nonisolated members, plain types. A non-Sendable value here belongs to whichever task is running the code.
A function can also borrow its caller's domain through an isolated parameter (isolation: isolated (any Actor)? = #isolation), so a helper runs inside the caller's actor instead of hopping.
How a value crosses
Sendable types: value types built from Sendable parts, actors, immutable final classes, types guarded by Mutex. Sharing them is safe, so no further proof is needed.
Disconnected non-Sendable values: region isolation lets a freshly created object cross if the sender never touches it again; sending writes that promise into a signature.
State never crosses, calls do: code in another domain reaches isolated state only through an awaited call, which hops to the owner's executor.
A class never gets Sendable inferred: you write the conformance, and then the class must be final, have only immutable stored properties of Sendable types, and no superclass except NSObject. @unchecked Sendable waives all of that and is honest only when a lock you wrote guards every access.
Explicit conformance: structs and enums infer it inside their module; classes never do, however immutable they look.
**final: a subclass could add mutable storage, so a non-final class cannot promise anything.
Sendable property types**: let text: NSMutableString fails: an immutable reference to a mutable object is still shared mutable state.
What @unchecked Sendable costs
It switches off every check above at once. You assert that something the compiler cannot see serialises all access, and that this stays true after the next person edits the file.
Honest cases: a legacy class whose private state is always touched under one NSLock, or a wrapper around a documented thread-safe C or Objective-C object.
Better: keep the state in a Mutex (iOS 18+), which is Sendable, so the class conforms checked; or make it an actor if callers can await.
Line (1) is a global variable any thread can write with nothing serialising it. Line (2) is immutable, but it points at a non-Sendable class with var storage, so the state behind it is still shared and mutable. Every fix makes the state immutable, isolated or guarded.
(1) "static property 'currentUserID' is not concurrency-safe because it is nonisolated global shared mutable state".
(2) "…because non-'Sendable' type 'Analytics' may have shared mutable state". let alone is not enough: the type behind it has to be safe too.
The legal shapes
**static let of a Sendable type**: make Analytics an actor, or a final class that keeps its state in a Mutex. Lazy static initialisation is already thread-safe.
Isolate it: @MainActor static var currentUserID: String?. Callers off the main actor must await it, and that hop is the point.
Guard it: static let launches = Atomic<Int>(0) or Mutex<[String: Int]> from the Synchronization module (iOS 18+).
Last resort: nonisolated(unsafe) static var plus a lock you manage yourself. It compiles and checks nothing.
The compiler is asking one question: who serialises this? let of a Sendable type, an actor and a lock are all answers; silence is not.
It switches off concurrency checking for one declaration and promises that you serialise every access yourself; it adds no lock and no runtime protection. It is honest only when you can write "this is safe because…" next to it and point at the mechanism.
It applies to a global or static var, a stored property or a local (SE-0412). It covers the declaration, never the object the value points to.
Honest uses
Storage that a lock declared right beside it guards on every path.
A value written once at launch, before any concurrent code exists, and only read afterwards.
A C handle whose thread-safety is documented on the C side, where the compiler cannot look anyway.
A silencer
Anything you cannot justify in one sentence. nonisolated(unsafe) static var cache: [URL: Data] compiles and races on the first concurrent write.
A let of a Sendable type never needs it: lazy global initialisation is already thread-safe.
Prefer an alternative the compiler keeps checking: @MainActor, Mutex or Atomic, or an actor.
Mutex<Value> (iOS 18+) is a lock that owns the value it protects: the value is reachable only inside withLock, and the mutex itself is Sendable, so a class that holds one conforms to Sendable without @unchecked.
Owns its state: with NSLock the lock and the var are separate, nothing stops a path that forgets to lock, and the class ends up @unchecked Sendable. With Mutex the unlocked access cannot be written.
Non-copyable: Mutex is ~Copyable, so it lives in a let of a class or actor, or in a global or static let.
Synchronous only: withLock takes a non-async closure, so you cannot hold it across an await, a mistake NSLock happily compiles.
Not recursive: never call withLock on the same mutex from inside its own closure.
**OSAllocatedUnfairLock** (iOS 16+): the same "lock owns the state" idea for older deployment targets. Its withLock requires a @Sendable closure and result; withLockUnchecked drops that rule.
counts[host, default: 0] += 1 goes through the getter and then the setter, so it takes the lock twice: it copies the dictionary under one lock, increments the copy unlocked and writes it back under another. Concurrent calls overwrite each other and increments are lost.
The compiler is satisfied, the logic is not: every access to the dictionary sits inside withLock, so the class is Sendable and Swift 6 accepts it. But get hands out a copy that shares the buffer, the += mutates that copy outside the lock, and set writes it back. That is a lost update, and the unlocked copy-on-write also races on the dictionary buffer, so Thread Sanitizer reports a Swift access race in record.
The setter is the trap: a locked get/set pair makes +=, append and subscript writes look atomic at the call site. Hand-written @Atomic property wrappers over NSLock have the same flaw.
The fix: do the whole read-modify-write inside one withLock, expose intent methods (record, reset) and only a read-only snapshot. For a single counter, Atomic<Int> with wrappingAdd(1, ordering: .relaxed) does the same without a lock.
Catch it next time: run the tests under Thread Sanitizer, and add a test that calls record 100,000 times from DispatchQueue.concurrentPerform and asserts the exact total; the buggy version loses most of the increments. In review, treat a public setter on lock-guarded state as a red flag.
A global actor is one shared actor instance named by an attribute, so declarations spread across many types share one isolation domain; @MainActor is the built-in one. Define your own only for process-wide state touched from several types, like a database.
Declaring and applying one
@globalActor actor StorageActor { static let shared = StorageActor() }: the protocol only asks for a static shared actor.
@StorageActor on a type isolates all its members; on a protocol, conformers inherit it; on a function or property, just that declaration.
Unlike @MainActor, a custom global actor is not tied to a thread: it runs on the cooperative pool like any actor.
A declaration has one isolation because its body runs on one executor, so @MainActor @StorageActor func is an error ("declaration can not have multiple global actor attributes"). To use both, be isolated to one and await the other; each hop is a suspension, so the other actor's state may have changed on return.
When a plain actor is better
If one object owns the state, an actor gives the same exclusion without spreading an attribute across the module.
nonisolated takes a declaration out of its actor's isolation domain: callers reach it without await, and it may not touch isolated state. It is a statement about checking, not scheduling; none of the three forms moves work to a background thread by itself.
Stored property
Allowed on a let of a Sendable type. Inside the module such a let is already readable without await; nonisolated makes that true for other modules too.
Since SE-0434 also allowed on a Sendable var in a global-actor-isolated struct. A mutable property of a class needs nonisolated(unsafe), which is a promise, not a proof.
Synchronous method
Runs on the caller's thread. Use it for protocol witnesses (==, hash(into:), description) and members that read only immutable state.
Called from the main actor, a heavy nonisolated sync method still blocks the main thread.
async method
With NonisolatedNonsendingByDefault (Swift 6.2, part of Approachable Concurrency) it runs on the caller's actor. Without that feature it always runs on the global pool.
@concurrent asks for the pool explicitly; nonisolated(nonsending) asks for the caller's actor explicitly.
As written it fails with "sending 'report' risks causing data races", because report is read after being handed to the actor. Delete the print and it compiles: region-based isolation (SE-0414) proves nothing else can reach report after the call.
How regions work
The compiler groups values into regions. Two values join one region once one can reach the other: an assignment, a capture, being passed to the same call.
A region nothing else can reach is disconnected and may be sent across an isolation boundary. Sending consumes it: every value in it is off-limits afterwards, and a read counts as a use.
The analysis is per function and flow-sensitive, so the same call can be legal on one line and rejected a few lines later.
What else makes it fail
Merging into longer-lived state: self.last = report, or appending it to an array you keep, before the call. The region is no longer disconnected.
A parameter: in func archive(_ report: Report, to: Archive) the value is task-isolated: the caller may still hold it. Mark the parameter sending to move the proof to the caller.
Report is still not Sendable. This is ownership transfer, not thread safety: you prove there is one reference and that you are giving it away.
@Sendable restricts a closure's captures to Sendable values, because it may run anywhere any number of times. sending says nothing about the type: it transfers one value, so the caller gives it up and the callee receives it disconnected.
**@Sendable** is part of a function type. Capturing a non-Sendable Draft fails with "capture of 'draft' with non-Sendable type 'Draft' in a '@Sendable' closure", however carefully the caller behaves.
**sending on a parameter: the argument must be disconnected at the call site and is consumed by the call. On a result, the callee promises a fresh, disconnected value, so the caller may send it on.
The error only sending fixes**: a helper that forwards a non-Sendable parameter to an actor. Inside it the parameter is task-isolated, so the call fails with "sending 'draft' risks causing data races". sending pushes the obligation to the caller, where the value really is a fresh local. Declaring the type @unchecked Sendable would be a lie.
SE-0430: Task.init and TaskGroup.addTask take sending closures. A task closure runs once, so it only needs its captures handed over, not shareable forever. That is why Task { print(draft.body) } compiles while a hand-written @Sendable callback rejects the same capture, as long as the caller does not touch draft afterwards.
In short: @Sendable means "safe to call from anywhere, any number of times"; sending means "yours now, no longer mine".
With NonisolatedNonsendingByDefault (SE-0461, Swift 6.2) a nonisolated async function runs on its caller's actor, so decode now runs on the main actor and decodes every image on the main thread. Mark it @concurrent to send it back to the global pool.
Before the switch: a nonisolated async function always left the caller's actor (the SE-0338 rule), so this code happened to decode off the main thread. The same source changed meaning when a build setting flipped.
Why Swift changed it: calling a nonisolated async method used to be a boundary crossing, so non-Sendable arguments were errors. Staying in the caller's domain removes that crossing, and most async helpers only await I/O, where staying put costs nothing.
The fix: @concurrent static func decode(_:). It always runs on the pool, so its arguments must be Sendable (Data is); a freshly created result such as UIImage may cross back.
Audit after flipping the flag: find nonisolated async functions that do CPU work (decoding, parsing, image processing) and mark them @concurrent. nonisolated(nonsending) is the explicit spelling of the new default.
An await marks a possible suspension, not a thread switch; it never meant "in the background".
Target by target, leaves of the dependency graph first: in Swift 5 mode turn on complete checking so every violation is a warning, drive the warnings to zero, then flip that target to Swift 6. Every step ends in a green build you can ship.
The order
Leaf modules first: a module that imports unannotated code inherits its problems, so work up the graph.
**StrictConcurrency in Swift 5 mode** (the same as -strict-concurrency=complete): most of the work. targeted is a cheaper first pass on a big legacy target.
The narrow upcoming features, one pull request each: GlobalConcurrency, InferSendableFromCaptures, DisableOutwardActorInference, IsolatedDefaultValues.
**.swiftLanguageMode(.v6) or SWIFT_VERSION = 6**: remaining warnings become errors and runtime isolation checks are added.
Decide early
Whether app targets use MainActor default isolation and Approachable Concurrency. They change semantics, so do that in its own commit before the bulk of the work, not halfway through.
How to treat third-party code: @preconcurrency import as a dated loan, removed when the dependency ships annotations.
Test targets and app extensions have their own settings and need the same pass.
Common mistakes
Flipping the whole workspace to Swift 6 in one commit: hundreds of errors and no shippable state in between.
Mass @unchecked Sendable and nonisolated(unsafe) to get green. The build passes and the races stay.
@MainActor on everything until the errors stop, which moves CPU work onto the main thread.
Default MainActor isolation (SE-0466) makes every unannotated declaration @MainActor; Approachable Concurrency turns on a bundle of upcoming features that suit that model. In an app that removes most Sendable errors; in a framework it changes your public API's isolation for every client.
The two settings
Default Actor Isolation: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, or .defaultIsolation(MainActor.self) in SwiftPM (tools 6.2). Leaving the main actor becomes the explicit act: nonisolated, @concurrent or an actor.
Approachable Concurrency: SWIFT_APPROACHABLE_CONCURRENCY, an umbrella for upcoming features. The two that matter most here are NonisolatedNonsendingByDefault (nonisolated async functions run on the caller's actor) and InferIsolatedConformances (a main-actor type's conformances become main-actor isolated).
They are separate switches. New app projects in Xcode 26 turn both on.
Why an app target wants it
Views, view models and most app code were going to be main-actor anyway. With that as the default nothing crosses a boundary, nothing needs to be Sendable, and the diagnostics left over point at real concurrency.
The cost: CPU work lands on the main thread unless someone marks it @concurrent.
Why a library should not
Your public types and functions become @MainActor, so a client parsing JSON on a background actor must await your parser and run it on the main thread.
Changing the setting later is source-breaking for every client.
Annotate explicitly instead: @MainActor on UI-facing types, Sendable value types and actors for the rest.
Equatable.== is a nonisolated requirement, so any code may call it synchronously from any thread, and a main-actor witness cannot honour that. The fixes: a nonisolated witness, an isolated conformance (Swift 6.2), or a protocol that fits the isolation.
Why
@MainActor on the type isolates every member, == included. Swift 6.0 reports "conformance of 'Model' to protocol 'Equatable' crosses into main actor-isolated code and can cause data races".
The same collision hits Hashable, Comparable, CustomStringConvertible and older SDK delegate protocols.
Options
**nonisolated witness**: correct when it reads only immutable Sendable state, such as let id. Usually the answer.
Isolated conformance (SE-0470): extension Model: @MainActor Equatable. The witness may read main-actor state, and only main-actor code may use the conformance. InferIsolatedConformances infers this for main-actor types.
**An async requirement** in a protocol you own: the caller's await provides the hop.
An isolated protocol: @MainActor protocol Presentable, when every conformer is UI code.
The catch
An isolated conformance cannot be used where generic code might run off the main actor: the compiler rejects it for a Sendable or SendableMetatype generic parameter.
A @preconcurrency conformance also compiles, but turns the error into a runtime trap; keep it for legacy delegates.
On an import it stops diagnostics about missing Sendable on that module's types; on a conformance it lets an isolated type satisfy a nonisolated requirement, swapping the compile error for a runtime trap. Neither removes a race: one hides it, the other makes it a crash.
@preconcurrency import Legacy
Sendable diagnostics about types from that module are suppressed. Your own isolation errors and other modules are unaffected.
When the module ships concurrency annotations, the compiler warns that the attribute has no effect, which is the signal to delete it.
Risk: a type that is genuinely not thread-safe now crosses domains with no diagnostic at all.
extension Box: @preconcurrency LegacyDelegate
A @MainActor type may satisfy a nonisolated delegate protocol. The compiler inserts a dynamic isolation check into each witness (SE-0423).
Risk: if the SDK ever calls back off the main thread, the app traps in production. It is the right trade only when the SDK documents main-thread delivery.
How to treat them
As dated loans: keep a list, revisit on every dependency update, and run Thread Sanitizer over the paths behind a @preconcurrency import, because the compiler no longer checks them.
assumeIsolated checks at runtime that you are already on the main actor, then runs its closure synchronously with main-actor isolation; it never switches threads. It is right when main-thread delivery is guaranteed, and it traps, in release builds too, when it is not.
MainActor.assumeIsolated is static; other actors use the instance method assumeIsolated. Inside the closure you may touch isolated state directly, and the callback stays synchronous and in order.
vs Task { @MainActor in … }
A Task returns at once and runs the body later: ordering changes, the delegate method cannot return a value computed there, and captured arguments must be Sendable or handed off.
Use the Task when the thread is not guaranteed; assumeIsolated when it is and the work must stay synchronous.
Limits
It proves where you are, not that the state is race-free.
It cannot get you onto an actor: called off the main thread it is a crash, not a hop.
MainActor.preconditionIsolated() and assertIsolated() check the same thing without running isolated code.
For a whole delegate protocol, an isolated conformance (Swift 6.2) or a @preconcurrency conformance is often cleaner than wrapping each method.
Put one small nonisolated object between the SDK and your code: it is the only thing the vendor's threads touch, it owns no mutable state, and it forwards each event into an AsyncStream that an isolated model consumes. The boundary is crossed in one place, with Sendable values.
The shape
A final class Bridge: NSObject, VendorDelegate, Sendable with only let properties passes the checker honestly, so the SDK may call it from any thread.
It holds an AsyncStream.Continuation (makeStream is iOS 17+), which is Sendable and safe to yield from any thread. Events are Sendable enums or structs.
The model iterates for await event in bridge.events inside its own isolation, so its state never leaves it.
One-shot completion handlers become withCheckedContinuation. Resume exactly once, and wrap it in withTaskCancellationHandler so cancelling the task stops the SDK.
Teach the importer
If you own the headers, annotate them once: NS_SWIFT_SENDABLE on thread-safe classes, NS_SWIFT_UI_ACTOR on main-thread-only ones, NS_SWIFT_NONISOLATED on individual members. Every call site in every module benefits.
What not to do
@preconcurrency on the conformance asserts main-thread delivery; here it turns a race into a crash.
@unchecked Sendable on the model silences the boundary instead of moving it.
Both are right: Swift 6 proves that no unprotected Swift value crosses an isolation boundary in code it was allowed to check, while TSan watches real memory accesses at runtime, including code the compiler was told to skip or never saw. The race lives in that blind spot.
Blind spots
Waived checks: @unchecked Sendable, nonisolated(unsafe), @preconcurrency import. A lock taken on three paths and forgotten on the fourth compiles fine.
Pointers: UnsafeMutablePointer and OpaquePointer are not Sendable, but the usual workaround, an @unchecked Sendable wrapper or nonisolated(unsafe) around the pointer, compiles cleanly while two tasks write through it. A pointer from withUnsafeMutableBytes that escapes its closure is also a use-after-free.
Unchecked code: targets still in Swift 5 mode, binary frameworks, C and Objective-C bodies, C globals.
**deinit**: it is nonisolated by default, so a @MainActor class's deinit runs on whichever thread drops the last reference. Invalidating a Timer there is off the main thread; isolated deinit (SE-0371, Swift 6.2) fixes it.
Not data races at all: reentrancy bugs, which neither the compiler nor TSan reports, and deadlocks, where TSan reports only lock-order inversions between real locks.
Hunting it
Enable Thread Sanitizer in the scheme's Diagnostics and run the racing path under load, such as a test that fires hundreds of tasks. TSan sees only accesses that happen; expect a 5-15x slowdown, and it cannot run together with Address Sanitizer.
Read both stacks. "Previous write … by thread N" is the other half of the race and usually names the module that bypassed isolation.
TSAN_OPTIONS=halt_on_error=1 stops at the first report with the state intact in the debugger.
Fix it with an owner the compiler checks, an actor or a Mutex, not another annotation.