How do you migrate an existing app to Swift 6 and strict concurrency without breaking it?
Predict first
A static mutable property in Swift 6 language mode:
enum Config { static var counter = 0 }
Config.counter += 1
print(Config.counter)
- Compiles and prints 1
- Does not compile: it is nonisolated global mutable state
Predict first
The same code in Swift 5 mode with complete checking:
enum Config { static var counter = 0 }
Config.counter += 1
print(Config.counter)
- An error
- A warning, and it runs and prints 1
Mental model. Migrating is renovating one room at a time while living in the house: you do the plumbing at the base first (the shared models), then upper floors, and keep a list of temporary scaffolding (@preconcurrency) to remove.
Swift 6 mode turns the concurrency checks that were warnings into errors, so you migrate in steps instead of flipping a switch. The checking level can be raised without changing the language mode: minimal (the Swift 5 default: only code that already adopts concurrency is checked), targeted (checks code that uses concurrency features) and complete (the full Swift 6 checks, as warnings), set through the SWIFT_STRICT_CONCURRENCY build setting or -strict-concurrency=complete. Individual upcoming features such as InferIsolatedConformances or default main-actor isolation can be enabled one at a time. A global or static var such as static var counter = 0 compiles silently in minimal mode, warns under complete in Swift 5 mode, and is an error in Swift 6 mode: “not concurrency-safe because it is nonisolated global shared mutable state”.
A sensible plan: 1) fix the build and the tests, and turn on complete for one module at a time, bottom-up (leaf modules and models first, since their Sendable annotations are what the rest depends on). 2) Fix the warnings by category: make value types Sendable, isolate UI types and view models to @MainActor, turn shared mutable state into actors or lock-protected Sendable wrappers, replace global mutable vars with let, @MainActor or an actor. 3) Wrap old callback and delegate APIs with @preconcurrency import for modules you do not control, and use @preconcurrency conformances for protocols from other modules; treat each as a to-do. 4) Convert GCD patterns to actors and async sequences where it is cheap, and leave stable DispatchQueue-based code that is correct (wrapped in a Sendable type) alone. 5) Only then switch the module to Swift 6 language mode and set it as the default for new targets. Test with Thread Sanitizer and the Swift Concurrency instruments, because the compiler proves data-race freedom only for what it can see: @unchecked Sendable and unsafe code are your responsibility. Measure the app’s behaviour after each step; the goal is the same program, with its races made visible.
Swift 6 mode rejects shared mutable global state that is not isolated or protected.
Complete checking in Swift 5 mode reports the problem as a warning, which is what makes step-by-step migration possible.
Common traps
- Flipping a whole app to Swift 6 mode at once. Hundreds of errors with no order; raise strictness per module instead.
- Silencing errors with
@unchecked Sendableandnonisolated(unsafe)everywhere. You compile, and the races remain.
They might follow up with
- Where do you start? The lowest-level, most shared modules, one at a time, with strict checking on.
- What is
@preconcurrency importfor? It downgrades Sendable diagnostics for a module you cannot change, until it is updated.
Related questions
- 10.5.1 How does region-based isolation work, and what does the sending keyword add on top of Sendable?
- 10.4.2 How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
- 10.7.1 How do you find and fix deadlocks, data races and slow main-actor hops?
← How does region-based isolation work, and what does the sending…