Swift 6 turns data-race checks from “nice to have” into “this won’t compile.” If your app is split across many internal frameworks, the smoothest path is incremental: surface Swift 6 diagnostics while staying on Swift 5, fix them module by module, then flip each target to Swift 6. This post shows the exact order of operations, the most common fixes (@MainActor, actor, Sendable, @Sendable, @preconcurrency).
A tiny story to set the scene
You’ve just returned from a break. Coffee in hand, you open the project: an app with many internal frameworks. You try a quick build, and you can already hear Swift 6 whispering: “I can keep you safe, but we need to talk about concurrency.”
There are two ways forward: flip everything and drown in errors, or make a small smart move and let momentum do the rest. This guide is the latter—the one you’ll actually follow when real life (and deadlines) return.
First move: make Swift 6 visible without breaking anything
Before changing language modes, let the compiler show you the truth while you’re still on Swift 5. Think of it like turning the lights on before cleaning.
- Xcode target → Swift Language Version: Swift 5, Strict Concurrency Checking: Complete
- SPM targets → enable upcoming features so you see Swift 6 checks as warnings.
// HERO: Flip one leaf framework first, protect downstream clients, and keep moving up.
// Package.swift (for that one framework you’re migrating now)
import PackageDescription
let package = Package(
name: "NetworkingCore",
platforms: [.iOS(.v15)],
products: [.library(name: "NetworkingCore", targets: ["NetworkingCore"])],
targets: [
.target(
name: "NetworkingCore",
swiftSettings: [
// Surface Swift 6 checks early (even before you set tools-version: 6.0)
.enableUpcomingFeature("StrictConcurrency"),
]
),
.testTarget(name: "NetworkingCoreTests", dependencies: ["NetworkingCore"])
]
)
Why not a “big bang”? Swift 6 makes strict concurrency the default. If you flip everything at once, you’ll face a wall of errors across all modules. The human-friendly pattern is: (1) turn on strict checks as warnings, (2) migrate leaf frameworks first (no dependents), (3) work upward, (4) app target last, and (5) use
@preconcurrencytemporarily to keep Swift‑5 clients compiling.
Meet the cast (in plain language)
Think of your code as a team working in different rooms.
@MainActor= “the UI room.” Anything that updates the UI belongs here.actor= “a room with a door.” Only one person edits the shared whiteboard at a time (serialized access).Sendable= “safe to pass between rooms.” Value types usually are; reference types need proof.@Sendable= “the note you pass under the door (closure) is safe.”@preconcurrency= “a temporary hallway pass.” It lets older code walk through while you renovate.
Short, sweet examples follow.
@MainActor: mark the things that touch UI
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var state: State = .idle
func load() async {
state = .loading
state = await service.load()
}
}
@MainActor
protocol ProfileRouting: AnyObject {
func showSettings()
}
actor: protect shared mutable state
actor ImageCache {
private var store: [URL: Data] = [:]
func data(for url: URL) -> Data? { store[url] }
func insert(_ data: Data, for url: URL) { store[url] = data }
}
Sendable: types that cross concurrency domains
// Value types often get Sendable implicitly
struct Article { var id: Int; var title: String }
// Safe reference type pattern: final + immutable
final class User: Sendable {
let id: UUID
let name: String
init(id: UUID, name: String) { self.id = id; self.name = name }
}
// If you must mutate, either move to an actor… or lock carefully (last resort)
final class MutableUser: @unchecked Sendable {
private var name = ""
private let lock = NSLock()
func updateName(_ new: String) {
lock.lock(); defer { lock.unlock() }
name = new
}
}
// Generics/enums only conform if their stored/associated types are sendable
struct Box<T: Sendable>: Sendable { let value: T }
enum AuthState: Sendable {
case loggedOut
case loggedIn(name: String)
}
@Sendable: closures that may hop threads/tasks
actor Articles {
func filter(_ isIncluded: @Sendable (Article) -> Bool) -> [Article] {
[]
}
}
let articles = Articles()
let keyword = "swift" // value capture → OK
let results = await articles.filter { $0.title.contains(keyword) }
@preconcurrency: a staging tool, not a lifestyle
Use it to keep Swift‑5 clients compiling while a framework is already on Swift 6.
@preconcurrency import LegacyAnalytics
@preconcurrency
public protocol LegacyOutput: AnyObject {
func didFinish()
}
Keep a ticket list and remove these as you migrate dependents.
A mini-migration you can copy
Let’s migrate a leaf framework named ImageKit.
Before (shared mutable dictionary):
final class ImageCache {
static let shared = ImageCache()
private var store: [URL: Data] = [:]
func get(_ url: URL) -> Data? { store[url] }
func put(_ data: Data, for url: URL) { store[url] = data }
}
Step 1 — see Swift 6 warnings in Swift 5 mode. Fix what you see: move shared state into an actor, mark closures @Sendable.
public actor ImageCache {
public static let shared = ImageCache()
private var store: [URL: Data] = [:]
public func data(for url: URL) -> Data? { store[url] }
public func insert(_ data: Data, for url: URL) { store[url] = data }
}
public func warmCache(_ urls: [URL], fetch: @Sendable (URL) async throws -> Data) async {
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask {
let data = try await fetch(url)
await ImageCache.shared.insert(data, for: url)
}
}
}
}
Step 2 — flip this framework to Swift 6. Clean up any newly elevated diagnostics.
Step 3 — if the app is still on Swift 5, add @preconcurrency import ImageKit on the app side temporarily. Ship the framework, then move to the next leaf.
Human-sized troubleshooting
- “Global variable is not actor-isolated” → make it immutable, mark
@MainActorfor UI, or move it into anactor. - “Capture of non-sendable type in @Sendable closure” → make the type
Sendable, capture a snapshot value, or run that work inside anactor. - “Main-actor method doesn’t satisfy protocol requirement” → mark the protocol
@MainActorso everything lines up. - Third‑party/Obj‑C types → confine to
@MainActor(UI), wrap in anactor, or use afinal @unchecked Sendablefaçade only if you truly own the invariants.
PR checklists you can paste
Per‑module
- Swift 5 + Strict Concurrency (Complete) → 0 warnings
- Public API actor‑annotated (
@MainActor,actor) where needed - Cross‑task closures are
@Sendable - Types crossing tasks are
Sendable - No new
@unchecked Sendable(or justified & documented) - Flipped to Swift 6 (target or tools‑version)
- Temporary
@preconcurrencyshims tracked for removal - Tests + TSAN green
Final app cutover
- All internal frameworks on Swift 6
@preconcurrencyminimized (tickets exist for leftovers)- No mutable shared state outside actors
- Zero concurrency diagnostics
- Ship
Deep‑dive: the full migration plan (everything you need in one place)
This section condenses the exact steps I use on multi‑module codebases. It complements the story and examples above.
0) Set the ground rules
- Toolchain: Use Xcode 16+ (Swift 6 toolchain) so you can opt into Swift 6 checks and still compile most targets in Swift 5 mode.
- What actually changes: In Swift 6 language mode, strict concurrency/data‑race checks are errors. In Swift 5 mode, you can enable the same checks as warnings to fix issues early.
How to turn on the checks without breaking builds
- Xcode targets → Build Settings → Strict Concurrency Checking = Complete, keep Swift Language Version = Swift 5.
- SPM targets → in
Package.swiftadd:
.target(
name: "MyModule",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
// Optional: roll out more flags gradually
// .enableUpcomingFeature("ImplicitOpenExistentials"),
// .enableUpcomingFeature("BareSlashRegexLiterals"),
// .enableUpcomingFeature("DisableOutwardActorInference"),
]
)
1) Plan the migration like a dependency tree
- Inventory your internal frameworks and sketch a dependency graph.
- Order of attack: migrate leaf frameworks first (few/no dependents), then mid‑layers, app target last.
- For SPM, keep most packages on
// swift-tools-version: 5.xwhile you migrate select leaves to6.0. Setting tools‑version to 6.0 opts that package into Swift 6 language mode.
2) Fix warnings first (still Swift 5)
With strict checks visible, clean each module while it still compiles in Swift 5.
- Resolve
@Sendableclosure captures, addSendablewhere needed, and isolate UI with@MainActor. - Prefer immutable data. Where mutation is required and shared across tasks, prefer
actorto synchronize access. - If diagnostics point at third‑party or Obj‑C types, decide whether to confine them to an actor (often
@MainActor) or wrap them.
3) Establish isolation & API rules per framework
A. UI & presentation
- Annotate UI‑touching types/APIs with
@MainActor(views, view models, routers, delegates called by UIKit/SwiftUI).
B. Shared mutable state
- Eliminate global mutable state or wrap it in an
actor:
actor ImageCache {
private var store: [URL: Data] = [:]
func data(for url: URL) -> Data? { store[url] }
func insert(_ data: Data, for url: URL) { store[url] = data }
}
- Globals that must remain can be
@MainActor, or (last resort)nonisolated(unsafe)with strong documentation.
C. Sendability
- Add
Sendableto value types that cross concurrency domains. - For reference types: make them
finaland internally synchronized before conforming. If you must use@unchecked Sendable, document why it’s safe and contain usage.
D. Protocols & delegates
- If conformers are main‑actor‑bound, make the protocol
@MainActorto avoid “actor‑isolated requirement” errors.
E. Closures
- Mark cross‑task closures
@Sendable. Audit captures (weakself, immutable snapshots) to satisfy sendability.
F. Objective‑C / C interop
- Audit imported types used across threads; confine them to one actor or wrap them behind an actor/Swift facade.
4) Stage your module migrations
For each framework:
- Fix warnings under Swift 5 mode + Complete checks.
- Flip the module to Swift Language Version = Swift 6 (or
// swift-tools-version: 6.0for SPM). Some warnings become errors; clean them. - Protect downstream Swift 5 clients temporarily with
@preconcurrency(on your declarations or on the import side). Track and remove over time.
5) CI, testing, and gating
- Add a mixed‑mode lane: build everything in Swift 5 with strict checks = Complete (warnings), plus any modules already on Swift 6.
- Add a Swift 6 lane that builds the migrated subset with language mode 6 to prevent regressions.
- Keep TSAN enabled in tests. Static checks remove many races; TSAN still catches logic issues.
6) Typical fixes you’ll apply a lot
- “Global variable is not actor‑isolated” → move into an
actor, mark@MainActor, or make it immutable. - “Non‑Sendable captured in
@Sendableclosure” → make the typeSendable, capture a value copy, or confine to an actor. - “Main‑actor‑isolated method doesn’t satisfy protocol” → mark the protocol
@MainActor(preferred) or use@preconcurrencytemporarily.
7) Don’ts (learned the hard way)
- Don’t flip everything at once. You’ll drown in errors—do leaves first.
- Don’t blanket
@preconcurrency importthird‑party libs. You’re silencing safety checks; keep a ticket to remove them. - Don’t over‑actor your code. Prefer immutability and clear ownership; use actors for genuine shared mutable state.
8) Final cutover checklist
- All frameworks compile in Swift 6 with zero concurrency diagnostics.
- No remaining
@unchecked Sendablewithout solid justification. - No lingering
@preconcurrencyunless documented and ticketed for removal. - App target flips to Swift 6 and ships.
Tip: Keep these as PR templates. Checking the boxes makes progress visible and prevents backsliding.
Where to go next
- Apple docs: Swift Concurrency overview
- Swift Book: Concurrency
- Apple guide: Updating an App to Use Swift Concurrency
- WWDC session: Explore structured concurrency in Swift (WWDC21)
Tutorials:
- Apple tutorial: Managing structured concurrency
Proposals:
- SE‑0296 — async/await
- SE‑0304 — Structured Concurrency
- SE‑0306 — Actors
- SE‑0302 — Sendable and @Sendable closures
- SE‑0316 — Global Actors
- SE‑0337 — Incremental migration to concurrency checking
- SE‑0412 — Strict concurrency for global variables
Testing & diagnostics:
- Apple docs: Data races (Thread Sanitizer, Swift access races)
- Apple docs: Diagnosing memory, thread, and crash issues early
- Swift stdlib: Clock — https://developer.apple.com/documentation/swift/clock · SE‑0329 — Clock, Instant, and Duration — https://forums.swift.org/t/se-0329-clock-instant-date-and-duration/53309
If this guide saved you a few hours, keep it handy for the next module. Migration is a series of small good decisions, not one big one.
Leave a comment