Chapter 14 — Adopting Swift 6 strict concurrency
The two dials: language mode and checking level
- Language mode (
SWIFT_VERSION=5or6): In Swift 5 mode, data-race problems are - Strict concurrency checking (
SWIFT_STRICT_CONCURRENCY=minimal/targeted/complete): how
flowchart LR
A["Swift 5 mode
+ complete checking
(problems are WARNINGS)"] -->|"fix warnings
incrementally"| B["Swift 5 mode
+ complete checking
(zero warnings)"] B -->|"flip the switch"| C["Swift 6 mode
(problems are ERRORS,
but you already fixed them)"]
+ complete checking
(problems are WARNINGS)"] -->|"fix warnings
incrementally"| B["Swift 5 mode
+ complete checking
(zero warnings)"] B -->|"flip the switch"| C["Swift 6 mode
(problems are ERRORS,
but you already fixed them)"]
The strategy in one sentence: turn on complete checking while still in Swift 5 mode, so every data-race problem shows up as a warning you can fix at your own pace without breaking the build — then flip to Swift 6 mode once the warnings are gone.
Migrate module by module
- Start at the leaves. Migrate your lowest-level modules first (models, utilities, networking) —
- Work upward toward the app target, which is usually the hardest (it's the most
@MainActor-heavy - Within a module: set
completechecking, fix warnings, then set Swift 6 mode.
The error patterns you'll actually hit
1. Non-Sendable value crosses a boundary
@MainActor func apply(_ c: ColorComponents) {}
func update(color: ColorComponents) async {
await apply(color) // ⚠️ sending non-Sendable 'color' risks data races
}
2. Global / static mutable state
var shared = Cache() // ⚠️ global mutable state is not concurrency-safe
class Analytics { static var events: [Event] = [] } // ⚠️ same
- Make it a
letif it never needed to change (let shared = Cache()whereCacheisSendable). - Isolate it to an actor or
@MainActor(@MainActor static var events) if it's genuinely UI-adjacent - Use a
Mutex(fromSynchronization) for a thread-safe mutable global without an actor. - Last resort:
nonisolated(unsafe)if you have external synchronization (Chapter 13) — a labeled
3. A dependency isn't migrated yet
@preconcurrency import LegacyKit // its non-Sendable types no longer hard-error
func use(_ widget: LegacyWidget) async {
await render(widget) // now a warning, not an error, pending LegacyKit's migration
}
4. Protocol conformance isolation mismatch
Upcoming-feature flags as stepping stones
What not to do
- Don't carpet-bomb with
@unchecked Sendable. Each one is an unverified promise (Chapter 12) that - Don't blanket-
@MainActoreverything to dodgeSendable. Making all your code main-actor-isolated - Don't
nonisolated(unsafe)global state to move on. Fix it with alet, an actor, or aMutex.
A concrete migration order
- Set
SWIFT_STRICT_CONCURRENCY = complete(still Swift 5 mode). Now problems are warnings. - Fix warnings by family: global mutable state first (biggest wins), then boundary crossings, then
- Add
@preconcurrency importfor un-migrated dependencies so they don't block you. - Reach zero warnings.
- Set
SWIFT_VERSION = 6. Because the warnings are gone, this should build cleanly — the flip is - Move to the next module up the dependency graph.
flowchart TB
S1["complete checking (Swift 5)
→ warnings"] --> S2["fix by family:
globals → crossings → conformances"] S2 --> S3["@preconcurrency imports
for un-migrated deps"] S3 --> S4["zero warnings"] S4 --> S5["Swift 6 mode
(anticlimactic)"] S5 --> S6["next module up"]
→ warnings"] --> S2["fix by family:
globals → crossings → conformances"] S2 --> S3["@preconcurrency imports
for un-migrated deps"] S3 --> S4["zero warnings"] S4 --> S5["Swift 6 mode
(anticlimactic)"] S5 --> S6["next module up"]
What we built in this chapter
- The migration strategy: turn on
completechecking in Swift 5 mode so problems are warnings, burn - Incremental, module-by-module adoption from the leaves upward, using per-module language mode
- Fixes for the four dominant error families: non-
Sendablecrossings (the Chapter 13 four fixes), - Upcoming-feature flags as bite-sized stepping stones toward Swift 6 semantics.
- A clear list of what not to do — no carpet-bombed
@unchecked Sendable, no panic@MainActor, no
Mental model to take away
- Separate the checking level from the language mode: crank checking to
completewhile problems - Migrate one module at a time, leaves first — a half-migrated app still ships.
- Most diagnostics are global mutable state and non-
Sendablecrossings; both have good fixes - The unsafe hatches (
@unchecked Sendable,nonisolated(unsafe)) undo the entire point of the