Swift 6 Concurrency Migration Playbook: Moving a Swift 5 Codebase to Strict Concurrency

The Swift 6 language mode turns data-race safety checks into compile errors. This playbook shows how to get an existing Swift 5 codebase there one module at a time: how to switch the checks on, which order to fix things in, and the real fixes for each diagnostic you will hit.

Quick answer: Migrate one module at a time. Leave the module in the Swift 5 language mode and turn on complete concurrency checking (SWIFT_STRICT_CONCURRENCY = complete in Xcode, .enableUpcomingFeature("StrictConcurrency") in SwiftPM), so every problem shows up as a warning while the build still works. Fix the warnings in a fixed order: global state, Sendable types, actor isolation, protocol conformances, then dependencies. Only then switch that module to Swift 6 (SWIFT_VERSION = 6 or .swiftLanguageMode(.v6)).

Difficulty: Advanced

You'll understand this better if you know: async/await, actors, and Sendable and @MainActor isolation. For the bigger picture of Swift 4 → 5 → 6 upgrades, see Swift Migration Guides.

1. What Changes in the Swift 6 Language Mode

Keep three separate things apart: the compiler version (the toolchain that ships with Xcode), the language mode (-swift-version 5 or 6), and your deployment target. A Swift 6 compiler builds Swift 5 mode code without complaint, and you choose the language mode per module. Your app target can stay in Swift 5 mode while a framework it links already uses Swift 6, and the other way around.

Switching a module to Swift 6 mode does two things:

  • It turns on complete data-race checking (actor isolation and Sendable checks across the whole module) and reports what it finds as errors.
  • It turns on every upcoming feature planned for Swift 6. The concurrency-related ones are StrictConcurrency, GlobalConcurrency, RegionBasedIsolation, InferSendableFromCaptures, DisableOutwardActorInference, IsolatedDefaultValues, DynamicActorIsolation and GlobalActorIsolatedTypesUsability. Run swiftc -print-supported-features to see the full list for your toolchain.
ConfigurationWhat you get
Swift 5 mode, default (minimal)Sendable checks only where code explicitly adopted Sendable. Actor-isolation checks only in code that uses concurrency features.
Swift 5 mode + targetedSendable and actor-isolation checks in all code that has adopted concurrency (async, actors, Sendable)
Swift 5 mode + completeThe Swift 6 checks across the whole module, as warnings ending in "this is an error in the Swift 6 language mode"
Swift 6 modeComplete checking as errors, plus runtime isolation checks (section 11)

Note: Swift 6 mode also adds runtime checks. If a @MainActor closure gets called on a background thread by code that was not checked, the app now traps instead of quietly racing. Plan time for this. Section 11 shows the crash and its fix.

2. Prerequisites and Versions

You wantRequires
Swift 6 language mode, sending, region-based isolationSwift 6.0 compiler (Xcode 16) or later
Per-target .swiftLanguageMode(.v5) in a manifestswift-tools-version: 6.0 or later
Isolated conformances (extension T: @MainActor P), default MainActor isolation, @concurrent, swift package migrateSwift 6.2 compiler (Xcode 26) or later
Mutex from the Synchronization moduleRuntime OS: macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2 or later

Do this before you turn on any checks:

  • Make sure the project builds cleanly in Swift 5 mode and that all tests pass. That clean build is your baseline.
  • Update dependencies. Many libraries have since added Sendable annotations, and every one of those removes warnings from your side.
  • Run CI on the same Xcode version you use locally. Concurrency diagnostics change between compiler releases.
  • You do not need to raise your deployment target. The language mode and the deployment target are independent. Only specific APIs such as Mutex have OS requirements.

3. Turn On Checking Incrementally

Xcode build settings

Build setting (UI name)KeyValues
Swift Language VersionSWIFT_VERSION5.0, 6.0
Strict Concurrency CheckingSWIFT_STRICT_CONCURRENCYminimal, targeted, complete (applies to Swift 5 mode only; Swift 6 mode always uses complete)
Default Actor IsolationSWIFT_DEFAULT_ACTOR_ISOLATIONnonisolated, MainActor (Xcode 26+)
Approachable ConcurrencySWIFT_APPROACHABLE_CONCURRENCYYES / NO (Xcode 26+)

Change these per target, not per project. An xcconfig file makes the state of each target easy to review in code:

Swift
// Networking.xcconfig: still Swift 5, all data-race issues as warnings
SWIFT_VERSION = 5.0
SWIFT_STRICT_CONCURRENCY = complete

// Core.xcconfig: migrated
SWIFT_VERSION = 6.0

Swift Package Manager

With swift-tools-version: 6.0, every target defaults to Swift 6 mode. Targets that are not ready opt back into Swift 5 mode and turn on complete checking as warnings:

Swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "Shop",
    platforms: [.macOS(.v15)],
    targets: [
        // Migrated: uses the package-wide Swift 6 mode
        .target(name: "Core"),

        // Not migrated yet: Swift 5 mode, complete checking as warnings
        .target(
            name: "Legacy",
            swiftSettings: [
                .swiftLanguageMode(.v5),
                .enableUpcomingFeature("StrictConcurrency")
            ]
        ),
        .executableTarget(name: "App", dependencies: ["Core", "Legacy"])
    ],
    swiftLanguageModes: [.v6]
)

On 5.9 or 5.10 tools, use .enableExperimentalFeature("StrictConcurrency") instead. Manifests with tools version 6.0 or later use swiftLanguageModes, which replaces swiftLanguageVersions (SE-0441).

Command line

To see how many warnings you would get, without editing any files:

Terminal
swift build -Xswiftc -strict-concurrency=complete
swift build -Xswiftc -swift-version -Xswiftc 6
swiftc -swift-version 6 -parse-as-library Sources/Core/*.swift -typecheck

If complete checking floods you with warnings, the Swift migration guide suggests a gentler start in Swift 5 mode. Turn on one upcoming feature at a time: GlobalConcurrency, InferSendableFromCaptures or DisableOutwardActorInference. Then move to complete checking.

4. The Order of Work

  1. Baseline. Get a clean Swift 5 build with all tests green on the current Xcode, and commit it.
  2. Pick a module. The migration guide suggests starting with the outermost module, usually the app target, because nothing depends on it and changes stay local. If many warnings trace back to one shared dependency you own, fix that dependency's global state and Sendable conformances first.
  3. Turn on complete checking as warnings for that module only (section 3).
  4. Fix by category, in this order:
    1. global and static mutable state (section 5),
    2. value types that should be Sendable (section 6),
    3. UI types and other main-thread code: add @MainActor (section 7),
    4. protocol conformances that cross isolation (section 8),
    5. values sent between tasks and actors (section 9),
    6. imports of modules you do not control (section 10).
    Each category removes warnings from the ones after it. Marking a type Sendable early, for example, makes many later "sending risks causing data races" warnings disappear.
  5. Describe what is already true. Don't redesign yet. If a type really is used only on the main thread, annotate it @MainActor. Leave the architecture changes for later. Where you need an escape hatch (nonisolated(unsafe), @unchecked Sendable, @preconcurrency), add a // TODO(concurrency) comment so you can find every unsafe opt-out later.
  6. Flip the module to Swift 6 mode when it has zero concurrency warnings. Run the tests, run them again with Thread Sanitizer (swift test --sanitize=thread), and exercise the app manually to catch the runtime isolation traps described in section 11.
  7. Repeat for the next module. When every target is done, set Swift 6 project-wide and work through the TODO list of unsafe opt-outs.

5. Fix Global and Static Mutable State

This is usually the largest group of diagnostics, and the most mechanical to fix. Any global or static var can be reached from any thread, so Swift 6 rejects it unless it is isolated or protected. (A top-level var in a main.swift file is implicitly main-actor isolated and does not trigger this.)

Before (Swift 6 mode, library code):

Swift
var requestCount = 0

final class Logger {
    static var shared = Logger()
    var lines: [String] = []
}

final class Settings {
    static let shared = Settings()
    var theme = "light"
}

The compiler reports:

  • var 'requestCount' is not concurrency-safe because it is nonisolated global shared mutable state
  • static property 'shared' is not concurrency-safe because it is nonisolated global shared mutable state
  • static property 'shared' is not concurrency-safe because non-'Sendable' type 'Settings' may have shared mutable state (for the let of a class that is not Sendable)

After: pick the fix that matches how the state is really used:

Swift
import Foundation

// Immutable and Sendable: nothing to do
let maxRetries = 3

// Only touched from UI code: isolate it to the main actor
@MainActor var requestCount = 0

final class Logger {
    @MainActor static var shared = Logger()
    var lines: [String] = []
}

// A singleton whose state is main-thread-only: isolate the whole type
@MainActor
final class Settings {
    static let shared = Settings()
    var theme = "light"
}

// Already protected by a lock you trust: opt out explicitly and leave a marker
// TODO(concurrency): replace with Mutex once the deployment target allows it
nonisolated(unsafe) var legacyFlags: [String: Bool] = [:]
let legacyFlagsLock = NSLock()

Prefer the fixes in this order: make it a let of a Sendable type, isolate it to a global actor, move it into an actor or behind a Mutex, and only as a last resort use nonisolated(unsafe). The last option compiles because you promise the access is synchronized. The compiler no longer checks that promise. These rules come from SE-0412.

6. Make Types Sendable

Swift infers Sendable for non-public structs and enums whose stored properties are all Sendable. Public types must declare the conformance, and classes need extra rules. When you declare it, the compiler verifies it:

Swift
import Foundation

final class ImageCache {
    var storage: [String: Data] = [:]
}

struct Account: Sendable {
    var owner: String
    var cache: ImageCache
}

final class Counter: Sendable {
    var value = 0
}
  • stored property 'cache' of 'Sendable'-conforming struct 'Account' has non-Sendable type 'ImageCache'
  • stored property 'value' of 'Sendable'-conforming class 'Counter' is mutable

The fixes, from most to least preferred:

Swift
import Foundation
import Synchronization

// 1. Value types made only of Sendable values
struct Account: Sendable {
    var owner: String
    var balance: Decimal
}

// 2. A final class with only immutable Sendable state
final class APIConfiguration: Sendable {
    let baseURL: URL
    let timeout: Duration
    init(baseURL: URL, timeout: Duration) {
        self.baseURL = baseURL
        self.timeout = timeout
    }
}

// 3. Mutable shared state behind a Mutex (macOS 15 / iOS 18 and later)
final class Counter: Sendable {
    private let value = Mutex(0)

    func increment() -> Int {
        value.withLock { count in
            count += 1
            return count
        }
    }
}

// 4. Mutable state that is naturally accessed asynchronously: an actor
actor ImageStore {
    private var storage: [String: Data] = [:]
    func insert(_ data: Data, for key: String) { storage[key] = data }
}

// 5. Last resort: you guarantee thread safety, the compiler stops checking
// TODO(concurrency): audit; every stored property must be guarded by `lock`
final class LegacyCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]

    func insert(_ data: Data, for key: String) {
        lock.lock()
        defer { lock.unlock() }
        storage[key] = data
    }
}

Note: @unchecked Sendable does not make a type thread-safe. It only tells the compiler to stop checking. Use it for types that are already synchronized, such as older classes guarded by a lock or a serial queue. If someone later adds a property and forgets the lock, nothing will warn them.

7. Fix Main Actor Isolation

Once a view model is @MainActor, synchronous callers that are not isolated to the main actor can no longer reach it:

Swift
@MainActor
final class ProfileViewModel {
    var name = ""
    func update(name: String) { self.name = name }
}

func refresh(model: ProfileViewModel) {
    model.update(name: "Ada")
}

Error: call to main actor-isolated instance method 'update(name:)' in a synchronous nonisolated context. The compiler suggests the two real fixes:

Swift
@MainActor
final class ProfileViewModel {
    var name = ""
    func update(name: String) { self.name = name }
}

// Fix A: the caller is also UI code, so isolate it
@MainActor
func refresh(model: ProfileViewModel) {
    model.update(name: "Ada")
}

// Fix B: the caller runs elsewhere, so make it async and hop with await
func refreshFromBackground(model: ProfileViewModel) async {
    await model.update(name: "Ada")
}

A related error comes from Task.detached, which drops the caller's isolation:

Swift
@MainActor
final class ViewModel {
    var title = ""
    func load() {
        Task.detached {
            self.title = "Loaded"
        }
    }
}

Error: main actor-isolated property 'title' can not be mutated from a nonisolated context. This one is an error even in Swift 5 mode. Use a plain Task, which inherits the main actor. Move the slow work into a function that runs off the main actor:

Swift
@MainActor
final class ViewModel {
    var title = ""
    func load() {
        Task {
            let fetched = await fetchTitle()   // suspends; the main actor is free meanwhile
            self.title = fetched               // back on the main actor
        }
    }
}

nonisolated func fetchTitle() async -> String {
    "Loaded"
}

Use nonisolated for members that do not touch isolated state, such as pure helpers or let constants of Sendable type. Code that is not isolated can then use them without await. If a nonisolated member reads isolated state, you get main actor-isolated property 'items' can not be referenced from a nonisolated context.

8. Fix Protocol Conformances That Cross Isolation

A @MainActor type cannot satisfy a protocol requirement that is not isolated, such as a delegate protocol, because callers could invoke it from any thread:

Swift
protocol Refreshable {
    func refresh()
}

@MainActor
final class FeedController: Refreshable {
    var items: [String] = []
    func refresh() { items.removeAll() }
}

Error: conformance of 'FeedController' to protocol 'Refreshable' crosses into main actor-isolated code and can cause data races. Choose one of four fixes:

Swift
protocol DownloadDelegate: AnyObject {
    func downloadDidFinish(bytes: Int)
}

// Option 1 (Swift 6.2+): isolated conformance, usable only on the main actor
@MainActor
final class DownloadsViewModel {
    var total = 0
}

extension DownloadsViewModel: @MainActor DownloadDelegate {
    func downloadDidFinish(bytes: Int) { total += bytes }
}

// Option 2: nonisolated witness for a callback documented to arrive on the main thread
@MainActor
final class ProgressViewModel: DownloadDelegate {
    var total = 0

    nonisolated func downloadDidFinish(bytes: Int) {
        MainActor.assumeIsolated {   // traps if this is not actually the main actor
            total += bytes
        }
    }
}

// Option 3: @preconcurrency conformance: compiles, and checks isolation at run time
@MainActor
final class StatusViewModel {
    var total = 0
}

extension StatusViewModel: @preconcurrency DownloadDelegate {
    func downloadDidFinish(bytes: Int) { total += bytes }
}

// Option 4: if you own the protocol, make the requirement async
protocol AsyncDownloadDelegate: AnyObject {
    func downloadDidFinish(bytes: Int) async
}

@MainActor
final class SummaryViewModel: AsyncDownloadDelegate {
    var total = 0
    func downloadDidFinish(bytes: Int) async { total += bytes }
}

The isolated conformance (SE-0470) is the most precise fix. The compiler then stops you from using the conformance outside the main actor. Assigning the view model to an any DownloadDelegate property from nonisolated code fails with main actor-isolated conformance of 'DownloadsViewModel' to 'DownloadDelegate' cannot be used in nonisolated context.

9. Fix Values Sent Between Isolation Domains

Swift 6 uses region-based isolation (SE-0414). A value that is not Sendable can still be passed to an actor or a new task, as long as the compiler can prove that nothing on the sending side uses it afterward. These are the diagnostics you will see when it cannot prove that:

Swift
import Foundation

final class ImageCache {
    var storage: [String: Data] = [:]
}

actor Store {
    var cache = ImageCache()
    func put(_ c: ImageCache) { cache = c }
}

func send(store: Store) async {
    let c = ImageCache()
    await store.put(c)
    c.storage["x"] = Data()       // used after it was handed to the actor
}

func process(cache: ImageCache) async {
    Task.detached {
        cache.storage["a"] = Data()   // cache is still reachable by the caller
    }
}
  • sending 'c' risks causing data races
  • passing closure as a 'sending' parameter risks causing data races between code in the current isolation context and concurrent execution of the closure

Fix it by making the handoff the value's last use, by marking parameters and results sending (SE-0430), or by making the type Sendable:

Swift
import Foundation

final class ImageCache {
    var storage: [String: Data] = [:]
}

actor Store {
    var cache = ImageCache()
    func put(_ c: sending ImageCache) { cache = c }
}

// The result is a fresh value the caller fully owns
func makeCache() -> sending ImageCache {
    let c = ImageCache()
    c.storage["seed"] = Data()
    return c
}

func fill(store: Store) async {
    let c = makeCache()
    c.storage["x"] = Data()   // mutate first...
    await store.put(c)        // ...then hand it off as the last use
}

10. Handle Modules You Don't Control

When a dependency has not adopted concurrency annotations, its classes look non-Sendable to your code. Swift 6.4 points you to the import:

Swift
import LegacyKit

struct Session: Sendable {
    let token: Token   // Token is a class from LegacyKit
}

You get stored property 'token' of 'Sendable'-conforming struct 'Session' has non-Sendable type 'Token', plus add '@preconcurrency' to treat 'Sendable'-related errors from module 'LegacyKit' as warnings on the import line. The fix:

Swift
// TODO(concurrency): drop @preconcurrency once LegacyKit ships Sendable annotations
@preconcurrency import LegacyKit

Treat this as temporary. In a test against a module built without concurrency checking, @preconcurrency removed the Sendable diagnostics for that module's types completely. It did not just turn them into warnings. A Sendable struct that stores one of those classes then compiles silently. Before you add the attribute, check whether the type really is safe to share. If it is not, wrap it in your own Sendable type, such as an actor, or copy out the Sendable values you need. The rules come from SE-0337.

11. Callback APIs and Runtime Isolation Traps

This pitfall gets past the compiler. In Swift 6 mode, synchronous closures isolated to the main actor that you pass to a module compiled without strict concurrency checking get a runtime isolation check (SE-0423). Here fetchToken comes from a Swift 5 module and calls its completion handler on a global dispatch queue:

Swift
import LegacyKit

@MainActor
final class SessionModel {
    var tokenValue = ""

    func start() {
        fetchToken { token in             // inferred @MainActor, but called on a background queue
            self.tokenValue = token.value
        }
    }
}

It compiles without warnings in both modes. Built in Swift 5 mode, it runs and silently mutates main-actor state on a background thread. Built in Swift 6 mode, the process stops with EXC_BREAKPOINT (SIGTRAP) the moment the closure runs. The backtrace shows _dispatch_assert_queue_fail, reached from the concurrency runtime's executor check (swift_task_isCurrentExecutorWithFlagsImpl). That is the data race finally being reported. Fix it at the boundary:

Swift
@preconcurrency import LegacyKit

// Option A: say the callback can run anywhere, then hop to the main actor
@MainActor
final class SessionModel {
    var tokenValue = ""

    func start() {
        fetchToken { @Sendable token in
            let value = token.value               // copy out a Sendable value
            Task { @MainActor in
                self.tokenValue = value
            }
        }
    }
}

// Option B (better): wrap the callback API once, then use async/await everywhere
func fetchTokenValue() async -> String {
    await withCheckedContinuation { continuation in
        fetchToken { token in
            continuation.resume(returning: token.value)
        }
    }
}

@MainActor
final class SessionModel2 {
    var tokenValue = ""

    func start() async {
        tokenValue = await fetchTokenValue()
    }
}

Search the codebase for completion handlers, NotificationCenter observers, KVO, timers, and delegate callbacks from C or Objective-C libraries. Any of them can deliver a call on a thread you did not expect. Exercise those code paths before you ship a Swift 6 build.

12. Mapping Table: Swift 5 Pattern to Swift 6 Replacement

Swift 5 patternSwift 6 replacement
static var shared = X()static let shared with a Sendable or @MainActor type
Global var used from UI code@MainActor var
Global var guarded by a lockMutex, or nonisolated(unsafe) var with a TODO
Class with a private serial queueactor, or final class: Sendable with Mutex
DispatchQueue.main.async { … }@MainActor function, or Task { @MainActor in … }
Task.detached to "get off the main thread"Task { } calling a nonisolated or @concurrent async function
Delegate conformance on a UI classextension T: @MainActor Delegate (Swift 6.2+)
Completion-handler APIwithCheckedContinuation or withCheckedThrowingContinuation wrapper
Class "known" to be thread-safefinal class: Sendable with immutable state, or @unchecked Sendable after an audit
Import of an older dependency@preconcurrency import (temporary)
Passing a fresh non-Sendable object to an actorsending parameter, with the handoff as the last use

13. Common Errors and Their Fixes

Messages are from Swift 6.4. Recent compilers add a bracketed diagnostic group such as [#MutableGlobalVariable] that links to an explanation on docs.swift.org. In Swift 5 mode with complete checking, the same text appears as a warning ending in "; this is an error in the Swift 6 language mode".

Message (abridged)Usual fix
var 'x' is not concurrency-safe because it is nonisolated global shared mutable statelet, @MainActor, Mutex, or nonisolated(unsafe) (section 5)
static property 'shared' is not concurrency-safe because non-'Sendable' type 'X' may have shared mutable stateMake X Sendable or @MainActor
stored property 'p' of 'Sendable'-conforming class 'X' is mutablelet, Mutex, or an actor
capture of 'x' with non-Sendable type 'X' in a '@Sendable' closureCapture a Sendable copy, or make X Sendable
call to main actor-isolated instance method 'm()' in a synchronous nonisolated context@MainActor on the caller, or make it async and await
main actor-isolated property 'p' can not be mutated from a nonisolated contextUse Task { } instead of Task.detached, or call an isolated method
conformance of 'X' to protocol 'P' crosses into main actor-isolated code and can cause data racesIsolated conformance, nonisolated witness, or @preconcurrency (section 8)
sending 'x' risks causing data racesDon't use x after the handoff, use sending, or make it Sendable
passing closure as a 'sending' parameter risks causing data races …Don't capture state the caller still uses. Copy values into the closure.
add '@preconcurrency' to treat 'Sendable'-related errors from module 'M' as warnings@preconcurrency import M after checking the types (section 10)

14. Swift 6.2+ Options: Default MainActor Isolation and Approachable Concurrency

Swift 6.2 added two opt-in settings that change the defaults instead of the rules:

  • Default actor isolation (SE-0466): SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, .defaultIsolation(MainActor.self) in a swift-tools-version: 6.2 manifest, or -default-isolation MainActor. Every declaration without an explicit annotation is inferred @MainActor, so the global-state and UI errors above disappear for code that really is single-threaded. This works well for app targets. It is usually the wrong choice for libraries whose types are used from many threads.
  • Approachable Concurrency (SWIFT_APPROACHABLE_CONCURRENCY = YES): according to Xcode's own description of the setting, it turns on DisableOutwardActorInference, GlobalActorIsolatedTypesUsability, InferIsolatedConformances, InferSendableFromCaptures and NonisolatedNonsendingByDefault. The last one (SE-0461) makes nonisolated async functions run on the caller's actor unless they are marked @concurrent.

Under default MainActor isolation, opt value types and background work out explicitly:

Swift
import Foundation

// Compiled with -default-isolation MainActor
nonisolated struct Point: Sendable {
    var x = 0, y = 0
    func moved() -> Point { Point(x: x + 1, y: y) }
}

struct Report: Sendable {
    var lines: [String]
}

@concurrent
func parseReport(_ data: Data) async -> Report {
    Report(lines: String(decoding: data, as: UTF8.self)
        .split(separator: "\n").map(String.init))
}

Without the nonisolated, Point becomes main-actor isolated. Calling moved() from Task.detached then fails with main actor-isolated instance method 'moved()' cannot be called from outside of the actor. If you enable NonisolatedNonsendingByDefault on existing code, swift package migrate --to-feature NonisolatedNonsendingByDefault (the migrate command arrived in Swift 6.2, SE-0486) adds @concurrent where needed to keep today's behavior and updates the manifest for you.

15. Pitfalls

  • Annotating everything @MainActor. It silences errors but moves work onto the main thread. Keep parsing, image decoding and disk I/O in nonisolated/@concurrent functions or actors.
  • Using @unchecked Sendable and nonisolated(unsafe) as defaults. They are promises the compiler cannot check. Track each one with a TODO and remove it later.
  • Actor reentrancy. State read before an await inside an actor may have changed after it. Re-check your assumptions after every suspension point, or keep critical sections synchronous.
  • Leaving @preconcurrency in place forever. It can hide real races in dependency types (section 10). Remove it when the dependency updates.
  • Testing only the compile. Runtime isolation traps (section 11) appear only when the code path runs. Run the full test suite and the app in Swift 6 mode.
  • Treating Task.detached as "background". It also drops priority inheritance and task-local values. A nonisolated/@concurrent async function called from a regular Task is usually what you want.
  • Forgetting test targets. Test targets are modules too, with their own language mode. Mark UI-facing tests @MainActor.

16. Verification Checklist

  • Every target's language mode is set on purpose and shows in the manifest or xcconfig.
  • Modules still in Swift 5 mode have complete checking on and zero concurrency warnings, or a tracked list of what is left.
  • git grep -n -E "nonisolated\(unsafe\)|@unchecked Sendable|@preconcurrency" lists only opt-outs that each have a TODO and a reason.
  • Unit and UI tests pass in Swift 6 mode, and swift test --sanitize=thread (or the Thread Sanitizer scheme option) reports nothing.
  • You have manually exercised the callback paths (completion handlers, delegates, notifications, timers) in a Swift 6 build.
  • CI builds with the same Xcode version you use locally.

17. Key Points

  • The language mode is set per module. You migrate one module at a time without raising the deployment target.
  • Swift 5 mode with complete checking reports the Swift 6 data-race errors as warnings, so you can fix issues while the build keeps working. A few isolation violations are errors in both modes.
  • Fix global state first, then Sendable types, isolation, conformances, sending, and dependencies.
  • Prefer real fixes (let, Sendable value types, actors, Mutex, @MainActor) over opt-outs (@unchecked Sendable, nonisolated(unsafe), @preconcurrency).
  • Swift 6 mode adds runtime isolation checks. Callbacks from code that was not checked can trap where Swift 5 silently raced.
  • Swift 6.2 adds isolated conformances, default MainActor isolation and @concurrent, which make app targets much easier to migrate.

18. Final Summary

Moving to the Swift 6 language mode means proving to the compiler that your shared mutable state is safe. Do it module by module. Turn on complete checking in Swift 5 mode, work through the warnings category by category, and switch the module to Swift 6 only when it is warning-free. Most diagnostics have a small, local fix: a let, a Sendable conformance, a @MainActor annotation, an isolated conformance, or a sending parameter.

Use the escape hatches deliberately and keep track of them. Expect the first Swift 6 builds to show runtime isolation traps in callback-heavy code. Those traps are real data races that were already in the app. For deeper background, read the official Swift 6 migration guide and its common compiler errors chapter.