Swift 6 Data Race Safety in Production: A Migration Guide
The release of Swift 6 marks a historic milestone in the evolution of the Apple development ecosystem. For the first time, complete data race safety is enforced at compiler level. This means the compiler statically guarantees that your program is free of data race bugs, eliminating an entire class of hard-to-debug concurrency problems.
However, migrating a mature production codebase to Swift 6 Concurrency can feel like an endless battle against compiler errors. In this article, we outline the pragmatic strategies we used to migrate a large iOS application to Swift 6, showing how to resolve the most common compiler warnings and errors.
The Goal: Complete Strict Concurrency Checks
To begin migrating, you should first enable strict concurrency checking in Swift 5.10 to locate all violation points:
// Swift Build Settings: Strict Concurrency Checking -> Complete
Once enabled, you will likely face three main categories of issues:
- Non-sendable types crossing isolation boundaries.
- Actor-isolated states accessed from non-isolated contexts.
- Legacy callbacks conflicting with modern
async/await.
Resolving Non-Sendable Type Violations
The compiler throws an error whenever you pass a class instance that isn’t thread-safe across task or actor boundaries. The quickest fix is converting data models from class to struct (which is value-typed and implicitly Sendable).
If you must use a class, you can mark it as @unchecked Sendable and guarantee safety using an internal synchronization lock:
import Foundation
public final class SafeConfigurationCache: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.portfolio.cache.sync")
private var storage: [String: String] = [:]
public func set(_ value: String, forKey key: String) {
queue.sync {
storage[key] = value
}
}
public func get(forKey key: String) -> String? {
queue.sync {
storage[key]
}
}
}
However, @unchecked Sendable should be a last resort. Instead, leverage Swift’s native synchronization features like the new Mutex type introduced in the latest runtime updates, or model your class as an actor.
Bridging Legacy Closures with @MainActor
Legacy frameworks often invoke callbacks on arbitrary background threads. When updating these APIs to call UI-updating code, enforce @MainActor isolation:
class ImageDownloader {
// Escaping closure must be marked as @Sendable and @MainActor if updating UI
func downloadImage(from url: URL, completion: @Sendable @escaping @MainActor (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url) { data, _, _ in
let image = data.flatMap(UIImage.init)
Task { @MainActor in
completion(image)
}
}.resume()
}
}
Migration Execution Timeline
Do not attempt to migrate your entire codebase overnight. Apply these step-by-step phases:
- Phase 1 (Audit): Enable Complete Concurrency Checks but keep warnings as warnings, not errors.
- Phase 2 (Isolate): Focus on isolating your networking and storage layers to dedicated Actors.
- Phase 3 (Enforce): Turn on Swift 6 mode module-by-module. Keep third-party dependencies wrapped in adapter layers if they do not yet support Strict Concurrency.