Swift 6 Migration Checklist for an Existing Xcode 16 App
Swift 6 migration goes much better when it is treated as compiler-assisted cleanup, not a dramatic flag flip. The language mode is opt-in, and Xcode 16 lets you turn on complete concurrency checking before you move a target to Swift 6.
The checklist below is the order I would use for an existing iOS app with app targets, internal frameworks, and package dependencies. Keep the shape: migrate one target at a time, fix root causes, and only switch the language version when that target is quiet under complete checking.
Availability notes
Swift 6 language mode is a compiler and target setting, not an iOS deployment target by itself. You still need normal @available checks for APIs that require a newer OS, but adopting the language mode is not the same thing as deciding your app now only supports the latest iOS release.
For Xcode projects, the two settings to know are Strict Concurrency Checking and Swift Language Version. In an xcconfig file, those map to:
SWIFT_STRICT_CONCURRENCY = complete
SWIFT_VERSION = 6
Complete strict concurrency checking is automatically part of Swift 6 language mode. The useful migration trick is enabling complete checking in Swift 5 mode, fixing the warnings while they are still staged as warnings, then changing SWIFT_VERSION to 6.
1. Inventory the targets
Start with a boring list: the app, app extensions, widgets, test support modules, internal frameworks, Swift packages you control, and binary or third-party SDKs you do not control.
For each target, write down:
- Current Swift language version
- Strict Concurrency Checking value
- Whether it has SwiftUI or UIKit main-thread code
- Whether it exposes public APIs to other modules
- Whether it owns shared mutable state, caches, singletons, delegates, or callbacks
That list keeps the migration concrete.
2. Pick the outside module first
Apple’s migration guidance uses a module-by-module strategy. In an app, I like starting with the outermost target that is not imported by other targets, often the app or an extension. Changes there tend to stay local. If a warning points into a dependency you own, make a note and fix the dependency next.
3. Turn on Complete checking in Swift 5 mode
In Xcode, open the target build settings and set Strict Concurrency Checking to Complete while leaving Swift Language Version at Swift 5. Build the target. The first build may be loud.
The goal is not to fix every line in order. Sort the warnings by root cause:
- UI state that should be main actor isolated
- Values crossing actor or task boundaries that need
Sendable - Closures that should be
@Sendable - Shared mutable globals and static variables
- Delegate or callback APIs with unclear isolation
- Third-party declarations without concurrency annotations
Fixing one root cause often clears several warnings, so do not blindly accept every fix-it.
4. Mark UI state with the actor it already uses
Most iOS UI state is main actor state in practice. Tell the compiler that truth.
import Foundation
import SwiftUI
@MainActor
final class TimelineModel: ObservableObject {
@Published private(set) var events: [TimelineEvent] = []
@Published private(set) var errorMessage: String?
private let service: TimelineService
init(service: TimelineService) {
self.service = service
}
func reload() async {
do {
events = try await service.events()
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
This documents the isolation the type already needed. The view model publishes values for UI, so reads and writes belong on the main actor. Once the type is isolated, the compiler will also catch accidental background mutation later.
The paired service and model values should be safe to cross boundaries:
struct TimelineEvent: Decodable, Identifiable, Sendable {
let id: UUID
let title: String
let date: Date
}
struct TimelineService: Sendable {
func events() async throws -> [TimelineEvent] {
// Fetch and decode events.
[]
}
}
Do not mark every type @MainActor because it makes warnings disappear. Keep CPU work, parsing, persistence, and networking models off the main actor. If a member of a main-actor-isolated type truly does not touch isolated state, nonisolated can be right, but prefer moving that work into a service when the helper starts to grow.
5. Make data that crosses boundaries Sendable
Sendable means a value can safely move across concurrency domains. Many plain structs with immutable Sendable properties can conform directly, like the TimelineEvent above. Reference types need more care. A mutable class is usually not safely sendable just because it compiles after an annotation. Prefer value types for payloads, actors for protected mutable state, and focused main actor isolation for UI objects. If you use @unchecked Sendable, add a short comment explaining the synchronization rule.
6. Replace shared mutable globals
Swift 6 checking is intentionally suspicious of global and static mutable state. That state is visible from anywhere, so the compiler cannot assume two tasks will not touch it at the same time.
Before:
enum ResponseCache {
static var storage: [URL: Data] = [:]
}
After:
actor ResponseCache {
private var storage: [URL: Data] = [:]
func data(for url: URL) -> Data? {
storage[url]
}
func insert(_ data: Data, for url: URL) {
storage[url] = data
}
}
That change may require call sites to use await, but it turns an implicit threading rule into an explicit API. For constants, prefer static let. For global services, pass dependencies into the types that need them instead of reaching through a mutable singleton.
7. Audit callbacks and detached work
Closures that run concurrently should usually be @Sendable. If an API stores a closure or invokes it from another task, annotate the contract:
struct ImagePipeline: Sendable {
var didFinish: (@Sendable (URL) -> Void)?
func prefetch(_ urls: [URL]) {
Task {
for url in urls {
didFinish?(url)
}
}
}
}
This may cause new warnings inside call sites that capture non-sendable objects. That is useful information. If the closure updates UI, hop to the main actor explicitly or make the callback API main actor isolated.
Be especially careful with Task.detached. It severs actor context, so captured values must really be safe to send. Many app-level tasks should be plain Task {} from an actor-isolated context instead.
8. Handle dependencies honestly
For code you own, prefer real annotations: Sendable, @MainActor, actors, and @Sendable closure types. For code you do not own, update to a newer SDK first. Many warnings disappear when a dependency has adopted concurrency annotations.
If a framework has not been annotated yet, @preconcurrency import SomeFramework can be a temporary bridge. Use it sparingly and keep a note in your migration checklist. It reduces the compiler’s ability to prove safety for that imported API, so it should not be the final answer for code you can update or wrap.
Sometimes the best wrapper is small: a @MainActor facade around a legacy HUD, an actor around an old cache, or an async function around a callback API. The wrapper states how your app is allowed to use the legacy API, even if the dependency itself has no actor annotations.
9. Run the target’s normal tests
After a target builds with complete checking, run the tests that exercise it. This catches places where a migration moved too much work onto the main actor or changed timing around callbacks. For app targets, also run a simulator smoke test through the migrated screens.
10. Flip the target to Swift 6
Once the target builds cleanly with complete checking in Swift 5 mode, change Swift Language Version to Swift 6. Build again. Some diagnostics that were warnings can become errors in Swift 6 mode, but the jump should be manageable if the previous steps were done seriously.
A migration checklist
For each module, I would use this checklist:
- Build cleanly before changing settings.
- Enable Complete strict concurrency checking while staying in Swift 5 mode.
- Mark UI-facing models, controllers, and coordinators with
@MainActorwhere true. - Make cross-boundary value types conform to
Sendable. - Replace mutable global or static state with constants, actors, or injected dependencies.
- Add
@Sendableto stored or concurrently executed closures. - Review
nonisolatedmembers,Task.detached, delegate callbacks, timers, notifications, and completion handlers. - Update dependencies before using
@preconcurrency import. - Document every
@unchecked Sendableand temporary bridge. - Run tests and at least one simulator smoke pass.
- Switch only that module to Swift 6 language mode.
- Move to the next module and repeat.
Swift 6 migration is not about making the compiler quiet by any means available. The best fixes express the concurrency story your app already meant to have: UI state on the main actor, shared mutable state protected, value payloads that can cross tasks safely, and dependency boundaries that tell the truth.
Further reading: