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

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 -rn to 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 @MainActor patterns 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 import is 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.assumeIsolated will 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:

  1. Audit first. Set SWIFT_STRICT_CONCURRENCY = targeted in Build Settings (not complete) to see warnings without errors. Fix warning categories from highest count to lowest.
  2. Migrate leaf modules first. Start with modules that have no internal dependencies — utilities, data models, network layers. These have the fewest knock-on effects.
  3. 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 @preconcurrency at 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)

ScenarioRecommendation
New greenfield projectStart with Swift 6 mode from day one
Large existing codebase (50k+ LOC)Use incremental module-by-module migration
Library/framework with public APIPrioritize — Swift 6 callers see warnings at your boundaries
App with heavy UIKit legacy codeUse @MainActor extension conformances for delegates
App with third-party SDKs not on Swift 6Use @preconcurrency import and file a bug with the maintainer
nonisolated(unsafe)Last resort only — requires external thread-safety guarantee
C/Objective-C interopUse @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 Sendable violations in classes by choosing @MainActor isolation (for UI/view models), final class + Sendable (for immutable types), or conversion to actor (for shared mutable state).
  • Replace mutable static var singletons with static let backed by an actor or @MainActor-isolated class.
  • Audit @Sendable closure captures — prefer capturing Sendable IDs over whole objects, and use [weak self] to break retain cycles.
  • For UIKit delegate protocols, use @MainActor extension conformances rather than fighting nonisolated method requirements.
  • Use @preconcurrency import for 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.