Kaikki artikkelit
21. syyskuuta 2026

Swift 6 strict concurrency: a migration guide for existing apps

Swift 6 strict concurrency turns data races into compile time errors. Here is the module by module process for migrating an existing app without a full rewrite.

Migrate an existing app to Swift 6 strict concurrency one target at a time, not in a single release. Apple's own sequence works on codebases of any size: enable Complete Concurrency Checking in Swift 5 mode, resolve the warnings, then flip the target to Swift 6 language mode. Each module locks in independently. This guide walks through that sequence, the errors you will hit most often, and where Swift 6.2 changes the calculus for teams starting the migration today.

Before you start

Strict concurrency checking exists to catch data races at compile time instead of at runtime, or worse, in a customer's crash log. It enforces two ideas: actor isolation, which code is allowed to touch which state, and Sendable conformance, which values are safe to pass across that boundary. Apps built before async and await tend to accumulate the exact patterns strict checking flags: global mutable singletons, delegate protocols with no defined isolation, and third party frameworks that predate the concurrency model entirely.

Set expectations before you start the clock. Apple's WWDC24 migration session is explicit that this should be its own pass, kept separate from feature work or a broader refactor. Mix the two and every regression becomes hard to attribute: was it the new feature, or the concurrency change underneath it? The same discipline applies to any SwiftUI codebase your team ships, the kind of groundwork we cover in shipping SwiftUI apps with confidence.

There is also a fork in the road worth knowing about up front. Swift 6.2, released in 2025, introduced an easier on ramp called Approachable Concurrency, covered in the step by step section below. Teams beginning a migration now have a real choice between the original model and the newer default isolation approach.

Before touching any code, do a quick inventory. List every target and package your app depends on. Note which ones you own and which are third party, and flag the third party dependencies that have not published a Swift 6 compatible release yet. A dependency stuck on an old concurrency model can block a target from reaching full Swift 6 mode even after your own code is clean, so it's worth knowing that up front rather than discovering it mid migration.

Step by step

1. Enable Complete Concurrency Checking, one target at a time

Turn on the build setting for a single module while the rest of the project stays in Swift 5 mode. This does not break the build. Instead, it surfaces every error that Swift 6 mode would eventually enforce, but as a warning, so you can see the full scope of the work before committing to it. Apple's guidance is to treat the compiler as a reviewer here: it points at real, specific isolation problems rather than requiring you to guess where the risk is.

2. Resolve the warnings in that target

Four categories account for most of the warnings a typical app sees.

Global and static mutable state triggers a "not concurrency safe" error, because more than one thread could read or write it at once. The fix depends on the value. Convert it to an immutable let when nothing needs to change it after creation. Isolate it to an actor such as @MainActor when it must stay mutable but only one actor touches it. Or, as a last resort, mark it nonisolated(unsafe) when synchronization already exists elsewhere and you can prove it.

Delegate callbacks are the second common source, since a plain protocol does not guarantee which actor its methods run on. If the protocol comes from a framework you do not own, mark your conformance @preconcurrency to adopt it incrementally. Need a synchronous entry point that immediately jumps to the right actor? Mark the method nonisolated and wrap its body in MainActor.assumeIsolated. If you own the protocol, the cleanest fix is to mark it @MainActor directly.

Values crossing an actor boundary need explicit Sendable conformance. Swift does not infer Sendable for public types automatically, since declaring a type Sendable is a public API guarantee, not an implementation detail. Value types are usually a quick fix: structs and enums become Sendable once every stored property is. Reference types are the harder case. A class typically needs to become an actor, or implement thread safety and Sendable by hand.

A fourth pattern shows up whenever a parameter type does not, and cannot easily, conform to Sendable. Rather than forcing that type across the boundary, accept a @Sendable closure that builds the value instead. The closure crosses safely and constructs the non-Sendable type on the correct side of it, sidestepping the conformance requirement without weakening the safety guarantee.

3. Flip the target to Swift 6 language mode, then repeat

Once a target's warnings are clear, enable Swift 6 language mode for that target specifically. This turns the same diagnostics into build errors, locking in the work and preventing a future change from quietly reintroducing a data race. Move to the next target and repeat the same three steps. A whole app refactor pass, if you want one, comes after every target is migrated, not before.

4. Consider Swift 6.2's Approachable Concurrency path

For teams starting the migration today, Swift 6.2 offers a materially different default. Rather than treating every declaration as isolated unless proven otherwise, a target can opt into default MainActor isolation, so view models and SwiftUI views no longer need hand annotation one by one. This is a good fit for app targets and UI heavy packages. Utility libraries and backend code should generally keep the original, non default behavior.

Enable Swift 6.2's five Approachable Concurrency features one at a time rather than all at once, and save NonisolatedNonsendingByDefault for last, since it's the one that changes runtime behavior rather than just diagnostics. Before turning it on, audit your async functions: mark real CPU heavy work, large parsing, image filtering, with @concurrent so it keeps running off the main actor instead of silently inheriting the caller's context.

Common mistakes

A mutable property on a type you have marked Sendable is the single most common trigger for a migration error. The fix depends on what the type is: a class generally needs to become an actor or gain real thread safety, while a struct simply needs Sendable added once its stored properties qualify.

Converting a class to an actor has a cost worth planning for, not discovering mid migration. An actor is implicitly final, and every method on it becomes asynchronous, so every call site now needs an await. That's a deliberate design choice in the language, and it ripples outward through every caller of that type.

Not every remaining warning is your app's fault. Several Apple frameworks have not fully adopted strict concurrency: SwiftData outside ModelActor types, Combine, XPC, and the Virtualization framework are common examples. Treat a stubborn warning in one of these as a framework gap to track, not a bug in your own code to chase indefinitely, and wrap the legacy callback in a CheckedContinuation where you need to bridge it into async code today.

Setting a single deadline for the whole app is also a common misstep. A per target schedule, tracked target by target in your normal project board, survives contact with reality far better than one "done by" date for the entire codebase, because targets vary enormously in how much legacy state they carry.

This module by module discipline, checking one target at a time before moving to the next, is the same compliance gate Kallos Labs runs internally before any App Store submission, part of our iOS engineering practice.

Frequently asked questions

Do I have to migrate my whole app to Swift 6 at once?

No. Swift 6's strict concurrency checking can be enabled per target while the rest of the app stays in Swift 5 mode, so a large app is migrated module by module rather than in one release.

What is the difference between Complete Concurrency Checking and Swift 6 language mode?

Complete Concurrency Checking turns on strict data race diagnostics as warnings inside Swift 5 mode, letting a target compile and run while surfacing every issue that would fail under Swift 6. Swift 6 language mode is the second step: it makes those same diagnostics into build errors, locking the target in.

Why does the compiler say a global variable is not concurrency safe?

Global and static mutable state can be read or written from more than one thread at once, which strict concurrency treats as a potential data race. Making the value an immutable let, isolating it to an actor, or marking it nonisolated(unsafe) when it is already protected by existing synchronization resolves the error.

Does converting a class to an actor break its public API?

Usually, yes in a visible way. An actor is implicitly final, and every method that touches its state becomes asynchronous, so every call site needs an await. It is a deliberate design cost, not a bug, and it is worth planning for before you start the conversion.