Migrating to Swift 6: A Practical Guide to Fixing Concurrency Errors
You flip your project to Swift 6 language mode and the compiler reports 47 errors. Don’t panic — in most codebases, 80% of errors fall into five categories, and each category has a mechanical fix that takes minutes once you recognize the pattern.
This is a practical, pattern-based migration guide. We’ll work through each category with real before/after code, cover incremental migration strategies for large teams, and finish with a checklist you can work through systematically. We won’t cover the theoretical foundations of actor isolation — if you need that background, start with Actors in Swift: Eliminating Data Races at Compile Time first.
Contents
- Enabling Strict Concurrency
- Category 1:
SendableViolations - Category 2: Global Variable Isolation
- Category 3: Closure Capture Warnings
- Category 4: Protocol Conformance Issues
- Category 5: Third-Party Code with
@preconcurrency - Before/After: Migrating a
NetworkManager - Advanced Usage
- When to Use (and When Not To)
- Summary: Migration Checklist
Enabling Strict Concurrency
Before fixing errors, you need to see them. There are two ways to enable Swift 6’s strict concurrency checking:
In an Xcode project: Go to your target’s Build Settings, search for “Swift Language Version,” and set it to Swift 6. Alternatively, search for “Strict Concurrency Checking” to enable checking without fully switching the language version — useful for auditing your codebase before committing to the migration.
In a Swift Package: Add the .swiftLanguageVersion setting to your Package.swift:
// Package.swift
let package = Package(
name: "PixarFilmKit",
platforms: [.iOS(.v17)],
products: [ ... ],
targets: [
.target(
name: "PixarFilmKit",
swiftSettings: [
.swiftLanguageVersion(.v6)
]
)
]
)
For a large codebase, use a per-target approach so you can migrate module by module without breaking the whole project at once. More on that in the Advanced Usage section.
Tip: Run
swift build 2>&1 | grep "error:" | sort | uniq -c | sort -rnto get a count of each unique error type before starting. This tells you which category is most prevalent in your codebase and where to invest your time first.
Category 1: Sendable Violations
What the error looks like:
error: type 'FilmStore' does not conform to the 'Sendable' protocol
error: sending 'store' risks causing data races
Sendable is the protocol that marks a type as safe to
share across concurrency domains (actors, tasks). Swift 6 enforces that anything passed across an actor boundary is
Sendable.
Structs and Enums: Free Conformance
struct and enum types with all-Sendable stored properties automatically conform to Sendable — no changes needed.
This is why the Swift team emphasized value types for data models:
// ✅ Automatically Sendable — all stored properties are Sendable
struct PixarFilm: Sendable {
let id: UUID
let title: String
let releaseYear: Int
let studio: Studio
}
enum Studio: Sendable {
case pixar
case disney
case dreamworks
}
If your struct contains a non-Sendable type (e.g., a class reference), the compiler will tell you. The fix is usually
to make the contained type Sendable or replace it with a value type.
Classes: The Core Challenge
Classes are the source of most Sendable errors because they have shared mutable state by design. There are three
patterns for making a class Sendable:
Pattern A: @MainActor isolation (most common for view models)
Isolating the entire class to @MainActor makes it Sendable because all access to its state is serialized through a
single actor:
// ❌ Before: not Sendable, mutable state accessible from any thread
class FilmStore: ObservableObject {
@Published var films: [PixarFilm] = []
@Published var selectedFilm: PixarFilm?
func load() {
Task {
films = try await FilmService().fetchAll()
}
}
}
// ✅ After — Pattern A: @MainActor makes it Sendable
@MainActor @Observable
final class FilmStore {
private(set) var films: [PixarFilm] = []
private(set) var selectedFilm: PixarFilm?
func load() async throws {
films = try await FilmService().fetchAll()
}
}
Pattern B: final class + Sendable + immutable state
If the class has no mutable state after initialization, mark it final and conform to Sendable explicitly:
// ✅ Pattern B: immutable final class
final class FilmMetadata: Sendable {
let title: String
let director: String
let releaseYear: Int
init(title: String, director: String, releaseYear: Int) {
self.title = title
self.director = director
self.releaseYear = releaseYear
}
}
Pattern C: actor
If the class manages shared mutable state that needs to be accessed from multiple concurrency contexts, convert it to an
actor:
// ✅ Pattern C: actor for shared mutable state
actor PixarFilmCache {
private var cachedFilms: [String: PixarFilm] = [:]
func film(forId id: String) -> PixarFilm? {
cachedFilms[id]
}
func cache(_ film: PixarFilm, forId id: String) {
cachedFilms[id] = film
}
}
Category 2: Global Variable Isolation
What the error looks like:
error: static property 'shared' is not concurrency-safe because
non-'Sendable' type 'PixarAPI' may have shared mutable state
warning: var 'defaultTimeout' is not concurrency-safe because it is
nonisolated global shared mutable state
Global and static mutable variables are the compiler’s primary target in Swift 6 because they’re accessible from any concurrency context simultaneously.
Singletons: Move to a Global Actor
The singleton pattern (static var shared) is the most common global variable in iOS codebases. The cleanest fix is to
isolate the singleton to @MainActor or another global actor:
// ❌ Before: static var is mutable, non-isolated global state
class PixarAPI {
static var shared = PixarAPI()
var baseURL = URL(string: "https://api.pixar.example.com")!
var timeout: TimeInterval = 30
}
// ✅ After — Option A: isolate to @MainActor
@MainActor
final class PixarAPI {
static let shared = PixarAPI() // `let` is safe; `var` still needs isolation
var baseURL = URL(string: "https://api.pixar.example.com")!
var timeout: TimeInterval = 30
private init() {}
}
// ✅ After — Option B: convert to actor
actor PixarAPI {
static let shared = PixarAPI()
var baseURL = URL(string: "https://api.pixar.example.com")!
var timeout: TimeInterval = 30
private init() {}
}
Note the change from var to let for the shared property itself — a static let that holds an actor or
@MainActor-isolated class is safe because let can’t be reassigned.
Global Constants: Usually Fine
Global let constants with Sendable types don’t need changes:
// ✅ Fine: let constant, Sendable type
let pixarBaseURL = URL(string: "https://api.pixar.example.com")!
let defaultTimeout: TimeInterval = 30
Global Variables That Can’t Be Moved: nonisolated(unsafe)
Sometimes you’re dealing with a global variable from an older API or a C interop that you genuinely cannot refactor. The
escape hatch is nonisolated(unsafe):
// Use only when you have external thread-safety guarantees (e.g., the
// underlying C library serializes access internally)
nonisolated(unsafe) var legacyPixarSDKContext: OpaquePointer? = nil
Warning:
nonisolated(unsafe)tells the compiler “I know what I’m doing — don’t check this.” It silences the error but provides zero safety. Only use it when you have an external synchronization guarantee that the compiler cannot see. Prefer the actor or@MainActorpatterns above.
Category 3: Closure Capture Warnings
What the error looks like:
error: passing closure as a 'sending' parameter risks causing data races
between code in the current task and concurrent execution of the closure
error: capture of 'self' with non-sendable type 'FilmDownloader'
in a '@Sendable' closure
@Sendable closures — those passed across actor boundaries
— must only capture Sendable values. This is enforced at call sites that accept @Sendable closures, including
Task { } and Task.detached { }.
Fix: Make the Captured Type Sendable
The most robust fix is to make the type being captured conform to Sendable, which brings you back to Category 1. But
for closures specifically, there are two additional tools:
Capture by value instead of by reference:
// ❌ Captures `self` — is FilmDownloader Sendable?
func downloadFilm(_ film: PixarFilm) {
Task {
await self.download(film)
}
}
// ✅ Capture specific Sendable values, use [weak self] for the object
func downloadFilm(_ film: PixarFilm) {
let filmID = film.id // Capture the Sendable ID, not the whole Film
Task { [weak self] in
await self?.downloadFilm(withID: filmID)
}
}
The sending keyword for function parameters (Swift 5.10+):
The sending keyword, introduced in SE-0430, allows you to
pass a non-Sendable value into an async context by transferring ownership rather than sharing it. The caller gives up
access to the value:
// Swift 5.10+ / Swift 6
func process(film: sending PixarFilm) async {
// We own `film` exclusively — safe to use across concurrency boundaries
await renderPipeline.process(film)
}
This is useful for large mutable data structures that you don’t want to make Sendable globally but need to pass into
an actor or task once.
@Sendable Function Types
When you store a closure that will be called from a different concurrency context, annotate its type with @Sendable:
// ❌ Non-@Sendable closure stored and passed to a Task
struct FilmProcessor {
var onComplete: (() -> Void)?
func process(film: PixarFilm) {
Task {
await heavyProcessing(film)
onComplete?() // ⚠️ Non-Sendable closure crossing actor boundary
}
}
}
// ✅ @Sendable closure type
struct FilmProcessor: Sendable {
var onComplete: (@Sendable () -> Void)?
func process(film: PixarFilm) {
Task {
await heavyProcessing(film)
onComplete?()
}
}
}
Category 4: Protocol Conformance Issues
What the error looks like:
error: main actor-isolated instance method
'tableView(_:numberOfRowsInSection:)' cannot satisfy
nonisolated protocol requirement
error: conformance of 'FilmListViewController' to protocol
'UITableViewDataSource' involves isolation mismatch
This category is most common when you have @MainActor-isolated types that conform to protocols with nonisolated
requirements — particularly delegate protocols from UIKit.
Fix A: Add nonisolated to the Implementation
If the method’s implementation is safe to call from outside the main actor, mark the implementation nonisolated:
@MainActor
class FilmListViewController: UIViewController, UITableViewDataSource {
var films: [PixarFilm] = []
// ✅ nonisolated satisfies the nonisolated protocol requirement
nonisolated func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
// ⚠️ Can't access `films` here — it's MainActor-isolated.
// Use a nonisolated snapshot or restructure data access.
0 // Simplified for clarity
}
}
The problem is that nonisolated methods can’t access @MainActor-isolated state. For UIKit data sources, this is a
real constraint. The practical fix is to maintain a separate non-isolated snapshot:
@MainActor
class FilmListViewController: UIViewController, UITableViewDataSource {
private var films: [PixarFilm] = []
// Nonisolated snapshot that UITableViewDataSource methods can read
nonisolated private let filmsSnapshot =
OSAllocatedUnfairLock<[PixarFilm]>(initialState: [])
func updateFilms(_ newFilms: [PixarFilm]) {
films = newFilms
filmsSnapshot.withLock { $0 = newFilms }
tableView.reloadData()
}
nonisolated func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
filmsSnapshot.withLock { $0.count }
}
}
Fix B: Annotate the Protocol Conformance with @MainActor
For protocols that are always called on the main thread (most UIKit delegate protocols are), you can annotate the conformance itself:
// ✅ The conformance is @MainActor — all methods run on main
@MainActor
extension FilmListViewController: UITableViewDataSource {
func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
films.count
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let film = films[indexPath.row]
// Configure and return cell
return UITableViewCell()
}
}
This is the cleanest approach for UIKit delegate protocols and is appropriate because UIKit guarantees main-thread calling for its delegate methods.
Category 5: Third-Party Code with @preconcurrency
What the error looks like:
error: call to main actor-isolated initializer 'init()' in a synchronous
nonisolated context (from module 'FirebaseFirestore')
warning: conformance of 'QuerySnapshot' to 'Sendable' unavailable in Swift 6
Your code may be perfectly migrated, but it depends on third-party SDKs that haven’t been updated for Swift 6 yet. The
compiler flags any call into non-Sendable types from those libraries.
@preconcurrency import
@preconcurrency import suppresses Sendable and
actor isolation warnings for types from the imported module. It’s the officially sanctioned way to interoperate with
Swift 5 libraries:
// Suppresses Sendable and isolation warnings from this module
@preconcurrency import FirebaseFirestore
@preconcurrency import ThirdPartyPixarSDK
@MainActor
final class FilmRepository {
private let db = Firestore.firestore() // No more isolation errors
func fetchFilms() async throws -> [PixarFilm] {
let snapshot = try await db.collection("pixarFilms").getDocuments()
return snapshot.documents.compactMap {
try? $0.data(as: PixarFilm.self)
}
}
}
Note:
@preconcurrency importis a temporary bridge, not a permanent solution. Track whether the library ships Swift 6 support and remove the annotation once it does — the compiler will tell you when it’s no longer needed by issuing a warning.
assumeIsolated for Known-Main-Thread Callbacks
Some older SDKs have callbacks that are documented to call back on the main thread, but the types aren’t annotated with
@MainActor. Use
MainActor.assumeIsolated
to assert this:
legacyPixarSDK.onFilmsLoaded = { films in
// We know from the docs this is called on the main thread
MainActor.assumeIsolated {
self.films = films.map(PixarFilm.init)
}
}
Warning:
MainActor.assumeIsolatedwill crash at runtime (in debug builds) if you’re wrong — if the callback is called on a background thread. Verify the SDK documentation before using this.
Before/After: Migrating a NetworkManager
Here’s a concrete end-to-end migration of a typical NetworkManager class. This pattern appears in almost every iOS
codebase.
Before (Swift 5, pre-concurrency):
// ❌ Swift 5 NetworkManager — multiple concurrency issues
class NetworkManager {
static var shared = NetworkManager() // ⚠️ Mutable static var
private let session: URLSession
var baseURL = URL(string: "https://api.pixar.example.com")! // ⚠️ Mutable
private var activeRequests: [UUID: URLSessionTask] = [:] // ⚠️ Unprotected
init(session: URLSession = .shared) {
self.session = session
}
func fetchFilms(completion: @escaping ([PixarFilm]?, Error?) -> Void) {
let url = baseURL.appendingPathComponent("/films")
let task = session.dataTask(with: url) { data, _, error in
guard let data = data else {
completion(nil, error)
return
}
let films = try? JSONDecoder().decode([PixarFilm].self, from: data)
DispatchQueue.main.async { // 🤞 Manually hop to main
completion(films, nil)
}
}
activeRequests[UUID()] = task
task.resume()
}
}
After (Swift 6-compatible):
// ✅ Swift 6 NetworkManager
actor NetworkManager {
static let shared = NetworkManager() // `let` — safe
private let session: URLSession
let baseURL: URL // `let` — immutable after init
// Protected by actor isolation — concurrent access is serialized
private var activeRequests: [UUID: Task<Data, Error>] = [:]
init(
session: URLSession = .shared,
baseURL: URL = URL(string: "https://api.pixar.example.com")!
) {
self.session = session
self.baseURL = baseURL
}
func fetchFilms() async throws -> [PixarFilm] {
let url = baseURL.appendingPathComponent("/films")
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode([PixarFilm].self, from: data)
}
// Cancellation is now explicit and type-safe
func cancelAllRequests() {
activeRequests.values.forEach { $0.cancel() }
activeRequests.removeAll()
}
}
enum NetworkError: Error, Sendable {
case invalidResponse
case decodingFailed
}
The actor version eliminates DispatchQueue.main.async entirely — callers of fetchFilms() are responsible for
updating UI on @MainActor, which is the correct separation of concerns. The activeRequests dictionary is now
protected by actor isolation without any manual locking.
Calling it from a view model:
@MainActor @Observable
final class FilmGalleryViewModel {
private(set) var films: [PixarFilm] = []
private(set) var error: Error?
func loadFilms() async {
do {
// Crossing actor boundary: NetworkManager actor → MainActor
// The compiler verifies this is safe
films = try await NetworkManager.shared.fetchFilms()
} catch {
self.error = error
}
}
}
Advanced Usage
Incremental Migration: Module by Module
For large teams, a big-bang migration is risky. The incremental approach:
- Audit first. Set
SWIFT_STRICT_CONCURRENCY = targetedin Build Settings (notcomplete) to see warnings without errors. Fix warning categories from highest count to lowest. - Migrate leaf modules first. Start with modules that have no internal dependencies — utilities, data models, network layers. These have the fewest knock-on effects.
- Set per-target language versions in Package.swift. Modules that are fully migrated use
.swiftLanguageVersion(.v6); those still in progress use.v5. The compiler enforces the boundary at module edges, so Swift 6 modules can safely call Swift 5 modules (with@preconcurrencyat the call site).
// Package.swift — mixed migration state
.target(
name: "PixarFilmKit", // Fully migrated
swiftSettings: [.swiftLanguageVersion(.v6)]
),
.target(
name: "LegacyPixarRenderer", // In progress
swiftSettings: [
.swiftLanguageVersion(.v5),
.enableExperimentalFeature("StrictConcurrency")
]
)
Using the Concurrency Sanitizer
The Swift concurrency sanitizer (-sanitize=thread) catches data races at runtime that the compiler can’t prove at
compile time — particularly in nonisolated(unsafe) code or C interop. Enable it in your scheme’s Run settings under
Diagnostics. Run your full test suite with it enabled before finalizing the migration.
Note: The thread sanitizer has significant runtime overhead (5–15x slowdown). Only enable it during testing, never in release builds.
Swift 6.2 and Approachable Concurrency
Swift 6.2 introduced changes that reduce the number of migration errors in common patterns — particularly around
non-Sendable types in @MainActor contexts and default isolation for closures. The full details are covered in the
Approachable Concurrency in Swift 6.2 post. If you’re migrating a new project
today (not an existing Swift 5 codebase), you may encounter significantly fewer errors than described here.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| New greenfield project | Start with Swift 6 mode from day one |
| Large existing codebase (50k+ LOC) | Use incremental module-by-module migration |
| Library/framework with public API | Prioritize — Swift 6 callers see warnings at your boundaries |
| App with heavy UIKit legacy code | Use @MainActor extension conformances for delegates |
| App with third-party SDKs not on Swift 6 | Use @preconcurrency import and file a bug with the maintainer |
nonisolated(unsafe) | Last resort only — requires external thread-safety guarantee |
| C/Objective-C interop | Use @preconcurrency import; consider wrapping in a Swift actor |
Summary: Migration Checklist
- Enable “Strict Concurrency Checking” (targeted) first to audit warnings before switching to Swift 6 language mode.
- Fix
Sendableviolations in classes by choosing@MainActorisolation (for UI/view models),final class + Sendable(for immutable types), or conversion toactor(for shared mutable state). - Replace mutable
static varsingletons withstatic letbacked by anactoror@MainActor-isolated class. - Audit
@Sendableclosure captures — prefer capturingSendableIDs over whole objects, and use[weak self]to break retain cycles. - For UIKit delegate protocols, use
@MainActorextension conformances rather than fightingnonisolatedmethod requirements. - Use
@preconcurrency importfor third-party SDKs that haven’t shipped Swift 6 support yet. - Migrate module by module in large codebases — leaf modules first, then upstream consumers.
With all five categories addressed, your Swift 6 migration should be complete. The next step is understanding how tasks and structured concurrency work under the hood — check out Tasks and Task Groups: Structured Concurrency in Practice to go deeper.