Chapter 14 — Adopting Swift 6 strict concurrency

The two dials: language mode and checking level

  • Language mode (SWIFT_VERSION = 5 or 6): 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)"]

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

  1. Start at the leaves. Migrate your lowest-level modules first (models, utilities, networking) —
  2. Work upward toward the app target, which is usually the hardest (it's the most @MainActor-heavy
  3. Within a module: set complete checking, 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 let if it never needed to change (let shared = Cache() where Cache is Sendable).
  • Isolate it to an actor or @MainActor (@MainActor static var events) if it's genuinely UI-adjacent
  • Use a Mutex (from Synchronization) 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-@MainActor everything to dodge Sendable. Making all your code main-actor-isolated
  • Don't nonisolated(unsafe) global state to move on. Fix it with a let, an actor, or a Mutex.

A concrete migration order

  1. Set SWIFT_STRICT_CONCURRENCY = complete (still Swift 5 mode). Now problems are warnings.
  2. Fix warnings by family: global mutable state first (biggest wins), then boundary crossings, then
  3. Add @preconcurrency import for un-migrated dependencies so they don't block you.
  4. Reach zero warnings.
  5. Set SWIFT_VERSION = 6. Because the warnings are gone, this should build cleanly — the flip is
  6. 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"]

What we built in this chapter

  • The migration strategy: turn on complete checking 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-Sendable crossings (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 complete while problems
  • Migrate one module at a time, leaves first — a half-migrated app still ships.
  • Most diagnostics are global mutable state and non-Sendable crossings; both have good fixes
  • The unsafe hatches (@unchecked Sendable, nonisolated(unsafe)) undo the entire point of the