.agents/skills/swift-concurrency/references/migration.md
Use this when:
Skip this file if:
actors.md, sendable.md, or threading.md.async-algorithms.md.Jump to:
Swift 6 doesn't fundamentally change how Swift Concurrency works—it enforces existing rules more strictly:
Important: You can adopt strict concurrency checking gradually while still compiling under Swift 5. You don't need to flip the Swift 6 switch immediately.
Course Deep Dive: This topic is covered in detail in Lesson 12.2: The impact of Swift 6 on Swift Concurrency
Before interpreting diagnostics or choosing a fix, confirm the target/module settings. These settings can materially change how code executes and what the compiler enforces.
| Setting / feature | Where to check | Why it matters |
|---|---|---|
| Swift language mode (Swift 5.x vs Swift 6) | Xcode build settings (SWIFT_VERSION) / SwiftPM // swift-tools-version: | Swift 6 turns many warnings into errors and enables stricter defaults. |
| Strict concurrency checking | Xcode: Strict Concurrency Checking (SWIFT_STRICT_CONCURRENCY) / SwiftPM: strict concurrency flags | Controls how aggressively Sendable + isolation rules are enforced. |
| Default actor isolation | Xcode: Default Actor Isolation (SWIFT_DEFAULT_ACTOR_ISOLATION) / SwiftPM: .defaultIsolation(MainActor.self) | Changes the default isolation of declarations; can reduce migration noise but changes behavior and requirements. |
NonisolatedNonsendingByDefault | Xcode upcoming feature / SwiftPM .enableUpcomingFeature("NonisolatedNonsendingByDefault") | Changes how nonisolated async functions execute (can inherit the caller’s actor unless explicitly marked @concurrent). |
| Approachable Concurrency | Xcode build setting / SwiftPM enables the underlying upcoming features | Bundles multiple upcoming features; recommended to migrate feature-by-feature first. |
A common migration experience:
Why this happens: Fixing isolation in one place often exposes issues elsewhere. This is normal and manageable with the right strategy.
Course Deep Dive: This topic is covered in detail in Lesson 12.1: Challenges in migrating to Swift Concurrency
Break migration into small, manageable chunks:
// Day 1: Enable strict concurrency, fix a few warnings
// Build Settings → Strict Concurrency Checking = Complete
// Day 2: Fix more warnings
// Day 3: Revert to minimal checking if needed
// Build Settings → Strict Concurrency Checking = Minimal
Allow yourself 30 minutes per day to migrate gradually. Don't expect completion in a few days for large projects.
When writing new types, make them Sendable from the start:
// ✅ Good: New code prepared for Swift 6
struct UserProfile: Sendable {
let id: UUID
let name: String
}
// ❌ Avoid: Creating technical debt
class UserProfile { // Will need migration later
var id: UUID
var name: String
}
It's easier to design for concurrency upfront than to retrofit it later.
For new projects, packages, or files:
You can enable Swift 6 for individual files in a Swift 5 project to prevent scope creep.
Focus solely on concurrency changes. Don't combine migration with:
Create separate tickets for non-concurrency refactors and address them later.
Don't blindly add @MainActor to fix warnings. Consider:
nonisolated the right choice?Exception: For app projects (not frameworks), consider enabling Default Actor Isolation to @MainActor, since most app code needs main thread access.
Course Deep Dive: This topic is covered in detail in Lesson 12.3: The six migration habits for a successful migration
Start with:
Why: Fewer dependencies = less risk of falling into the concurrency rabbit hole.
Before enabling strict concurrency:
// Update third-party packages to latest versions
// Example: Vapor, Alamofire, etc.
Apply these updates in a separate PR before proceeding with concurrency changes.
Provide async/await wrappers for existing closure-based APIs:
// Original closure-based API
@available(*, deprecated, renamed: "fetchImage(urlRequest:)",
message: "Consider using the async/await alternative.")
func fetchImage(urlRequest: URLRequest,
completion: @escaping @Sendable (Result<UIImage, Error>) -> Void) {
// ... existing implementation
}
// New async wrapper
func fetchImage(urlRequest: URLRequest) async throws -> UIImage {
return try await withCheckedThrowingContinuation { continuation in
fetchImage(urlRequest: urlRequest) { result in
continuation.resume(with: result)
}
}
}
Benefits:
Tip: Use Xcode's Refactor → Add Async Wrapper to generate these automatically.
For app projects, set default isolation to @MainActor:
Xcode Build Settings:
Swift Concurrency → Default Actor Isolation = MainActor
Swift Package Manager:
.target(
name: "MyTarget",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)
This drastically reduces warnings in app code where most types need main thread access.
Xcode Build Settings: Search for "Strict Concurrency Checking"
Three levels available:
@Sendable, @MainActor)Sendable conformancesSwift Package Manager:
.target(
name: "MyTarget",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency=targeted")
]
)
Strategy: Start with Minimal → Targeted → Complete, fixing errors at each level.
Even if the compiler doesn't complain, add Sendable to types that will cross isolation domains:
// ✅ Prepare for future use
struct Configuration: Sendable {
let apiKey: String
let timeout: TimeInterval
}
This prevents warnings when the type is used in concurrent contexts later.
Xcode Build Settings: Search for "Approachable Concurrency"
Enables multiple upcoming features at once:
DisableOutwardActorInferenceGlobalActorIsolatedTypesUsabilityInferIsolatedConformancesInferSendableFromCapturesNonisolatedNonsendingByDefault⚠️ Warning: Don't just flip this switch for existing projects. Use migration tooling (see below) to migrate to each feature individually first.
Course Deep Dive: This topic is covered in detail in Lesson 12.5: The Approachable Concurrency build setting (Updated for Swift 6.2)
Xcode Build Settings: Search for "Upcoming Feature"
Enable features individually:
Swift Package Manager:
.target(
name: "MyTarget",
swiftSettings: [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InferIsolatedConformances")
]
)
Find feature keys in Swift Evolution proposals (e.g., SE-335 for ExistentialAny).
Xcode Build Settings:
Swift Language Version = Swift 6
Swift Package Manager:
// swift-tools-version: 6.0
If you've completed all previous steps, you should have minimal new errors.
Course Deep Dive: This topic is covered in detail in Lesson 12.4: Steps to migrate existing code to Swift 6 and Strict Concurrency Checking
Swift 6.2+ includes semi-automatic migration for upcoming features.
Example warning:
// ⚠️ Use of protocol 'Error' as a type must be written 'any Error'
func fetchData() throws -> Data // Before
func fetchData() throws -> any Data // After applying fix
Use the swift package migrate command:
# Migrate all targets
swift package migrate --to-feature ExistentialAny
# Migrate specific target
swift package migrate --target MyTarget --to-feature ExistentialAny
Output:
> Applied 24 fix-its in 11 files (0.016s)
> Updating manifest
The tool automatically:
Package.swift to enable the featureAvailable migrations (as of Swift 6.2):
ExistentialAny (SE-335)InferIsolatedConformances (SE-470)Course Deep Dive: This topic is covered in detail in Lesson 12.6: Migration tooling for upcoming Swift features
Additional resource: Migration Tooling Video
Three refactoring options available:
⚠️ Known Issue: Refactoring can be unstable in Xcode. If you get "Connection interrupted" errors:
Before (closure-based):
func fetchImage(urlRequest: URLRequest,
completion: @escaping @Sendable (Result<UIImage, Error>) -> Void) {
URLSession.shared.dataTask(with: urlRequest) { data, _, error in
do {
if let error = error { throw error }
guard let data = data, let image = UIImage(data: data) else {
throw ImageError.conversionFailed
}
completion(.success(image))
} catch {
completion(.failure(error))
}
}.resume()
}
After (async/await):
func fetchImage(urlRequest: URLRequest) async throws -> UIImage {
let (data, _) = try await URLSession.shared.data(for: urlRequest)
guard let image = UIImage(data: data) else {
throw ImageError.conversionFailed
}
return image
}
Benefits:
Course Deep Dive: This topic is covered in detail in Lesson 12.7: Techniques for rewriting closures to async/await syntax
Suppresses Sendable warnings from modules you don't control.
// ⚠️ Third-party library doesn't support Swift Concurrency yet
@preconcurrency import SomeThirdPartyLibrary
actor DataProcessor {
func process(_ data: LibraryType) { // No Sendable warning
// ...
}
}
// TODO: Remove @preconcurrency when SomeLibrary adds Sendable support
// Last checked: 2026-01-07 (version 2.3.0)
@preconcurrency import SomeLibrary
The compiler will warn if @preconcurrency is unused:
'@preconcurrency' attribute on module 'SomeModule' is unused
Course Deep Dive: This topic is covered in detail in Lesson 12.8: How and when to use @preconcurrency
Swift 6 will include Transactional Observation (SE-475):
// Future API (not yet implemented)
let names = Observations { person.name }
Task.detached {
for await name in names {
print("Name updated to: \(name)")
}
}
Current alternatives:
@Observable macro for SwiftUIAsyncStream for custom observationCombine:
$searchQuery
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] query in
self?.performSearch(query)
}
.store(in: &cancellables)
Swift Concurrency:
func search(_ query: String) {
currentSearchTask?.cancel()
currentSearchTask = Task {
do {
try await Task.sleep(for: .milliseconds(500))
performSearch(query)
} catch {
// Search was cancelled
}
}
}
SwiftUI Integration:
struct SearchView: View {
@State private var searchQuery = ""
@State private var searcher = ArticleSearcher()
var body: some View {
List(searcher.results) { result in
Text(result.title)
}
.searchable(text: $searchQuery)
.onChange(of: searchQuery) { _, newValue in
searcher.search(newValue)
}
}
}
Don't think in Combine pipelines. Many problems are simpler without FRP:
// ❌ Looking for AsyncSequence equivalent of complex Combine pipeline
somePublisher
.debounce(for: .seconds(0.5))
.removeDuplicates()
.flatMap { ... }
.sink { ... }
// ✅ Rethink the problem with Swift Concurrency
Task {
var lastValue: String?
for await value in stream {
guard value != lastValue else { continue }
lastValue = value
try await Task.sleep(for: .seconds(0.5))
await process(value)
}
}
For complex operators: Check Swift Async Algorithms package.
Problem: sink closures don't respect actor isolation at compile time.
@MainActor
final class NotificationObserver {
private var cancellables: [AnyCancellable] = []
init() {
NotificationCenter.default.publisher(for: .someNotification)
.sink { [weak self] _ in
self?.handleNotification() // ⚠️ May crash if posted from background
}
.store(in: &cancellables)
}
private func handleNotification() {
// Expects to run on main actor
}
}
Why it crashes: Notification observers run on the same thread as the poster. If posted from a background thread, the @MainActor method is called off the main actor.
Solutions:
Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: .someNotification) {
await self?.handleNotification() // ✅ Compile-time safe
}
}
.sink { [weak self] _ in
Task { @MainActor in
self?.handleNotification()
}
}
Course Deep Dive: This topic is covered in detail in Lesson 12.9: Migrating away from Functional Reactive Programming like RxSwift or Combine
When migrating from Combine or RxSwift, you have multiple options for handling asynchronous patterns:
See: async-algorithms.md for detailed AsyncAlgorithms usage examples.
Before: Manual Debouncing
final class ArticleSearcher {
@MainActor private(set) var results: [Article] = []
private var currentSearchTask: Task<Void, Never>?
func search(_ query: String) {
currentSearchTask?.cancel()
currentSearchTask = Task {
do {
try await Task.sleep(for: .milliseconds(500))
await MainActor.run {
self.results = []
}
self.results = await APIClient.searchArticles(query)
} catch {
// Search was cancelled
}
}
}
}
// SwiftUI integration
struct SearchView: View {
@State private var searchQuery = ""
@State private var searcher = ArticleSearcher()
var body: some View {
List(searcher.results) { result in
Text(result.title)
}
.searchable(text: $searchQuery)
.onChange(of: searchQuery) { _, newValue in
searcher.search(newValue)
}
}
}
After: AsyncAlgorithms Debounce
import AsyncAlgorithms
@Observable
final class ArticleSearcher {
@MainActor private(set) var results: [Article] = []
private var searchQueryContinuation: AsyncStream<String>.Continuation?
private lazy var searchQueryStream: AsyncStream<String> = {
AsyncStream { continuation in
searchQueryContinuation = continuation
}
}()
func search(_ query: String) {
searchQueryContinuation?.yield(query)
}
func startDebouncedSearch() {
Task { @MainActor in
for await query in searchQueryStream.debounce(for: .milliseconds(500)) {
self.results = []
self.results = await APIClient.searchArticles(query)
}
}
}
}
// SwiftUI integration
struct SearchView: View {
@State private var searchQuery = ""
@State private var searcher = ArticleSearcher()
var body: some View {
List(searcher.results) { result in
Text(result.title)
}
.searchable(text: $searchQuery)
.onChange(of: searchQuery) { _, newValue in
searcher.search(newValue)
}
.onAppear {
searcher.startDebouncedSearch()
}
}
}
Benefits of using AsyncAlgorithms:
Before: Combine Publisher
import Combine
final class NotificationObserver: ObservableObject {
@Published private(set) var notifications: [AppNotification] = []
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.compactMap { notification in
notification.object as? AppNotification
}
.receive(on: DispatchQueue.main)
.assign(to: &$notifications)
}
}
After: Standard Library Notifications
@Observable
final class NotificationObserver {
@MainActor private(set) var notifications: [AppNotification] = []
func startObserving() {
Task {
for await notification in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
if let appNotification = notification.object as? AppNotification {
notifications.append(appNotification)
}
}
}
}
}
When to use each approach:
notifications(named:) for standard system notificationsAsyncChannel for custom multi-consumer notification scenarios@Observable + SwiftUI for UI state updatesBefore: Combine Merge
import Combine
final class MultiSourceLoader: ObservableObject {
@Published private(set) var items: [Item] = []
private var cancellables = Set<AnyCancellable>()
func loadFromAllSources() {
let source1 = APIClient.fetchItems(from: .source1)
let source2 = APIClient.fetchItems(from: .source2)
let source3 = APIClient.fetchItems(from: .source3)
Publishers.Merge3(source1, source2, source3)
.flatMap { items in
Just(items)
.delay(for: .seconds(0.1), scheduler: DispatchQueue.main)
}
.scan([]) { accumulated, new in
accumulated + new
}
.receive(on: DispatchQueue.main)
.assign(to: &$items)
.store(in: &cancellables)
}
}
After: AsyncAlgorithms Merge + TaskGroup
import AsyncAlgorithms
@Observable
final class MultiSourceLoader {
@MainActor private(set) var items: [Item] = []
func loadFromAllSources() async {
let sources = [
APIClient.fetchItems(from: .source1),
APIClient.fetchItems(from: .source2),
APIClient.fetchItems(from: .source3)
]
Task { @MainActor in
for await stream in sources.map { $0.values }.merge() {
for await newItems in stream {
self.items.append(contentsOf: newItems)
}
}
}
}
// Alternative: Using TaskGroup for parallel execution
func loadFromAllSourcesParallel() async {
await withTaskGroup(of: [Item].self) { group in
group.addTask {
await APIClient.fetchItems(from: .source1)
}
group.addTask {
await APIClient.fetchItems(from: .source2)
}
group.addTask {
await APIClient.fetchItems(from: .source3)
}
for await newItems in group {
await MainActor.run {
self.items.append(contentsOf: newItems)
}
}
}
}
}
Key differences:
merge() combines publishers; AsyncAlgorithms merge() combines sequencesTaskGroup instead of flatMap@MainActor instead of .receive(on:)// ❌ Bad: Manual debouncing without backpressure
func search(_ query: String) {
Task {
try? await Task.sleep(for: .milliseconds(500))
await performSearch(query)
}
}
Problem: Every keystroke spawns a new task. If user types fast, multiple tasks execute simultaneously after 500ms, causing out-of-order results and wasted API calls.
Solution: Use debounce() from AsyncAlgorithms for automatic backpressure and cancellation.
// ❌ Bad: Manual combination without operator
actor FormValidator {
private var currentUsername: String = ""
private var currentEmail: String = ""
private var currentPassword: String = ""
func updateUsername(_ username: String) {
currentUsername = username
checkForm()
}
func updateEmail(_ email: String) {
currentEmail = email
checkForm()
}
func updatePassword(_ password: String) {
currentPassword = password
checkForm()
}
private func checkForm() {
let state = validate(
username: currentUsername,
email: currentEmail,
password: currentPassword
)
// Update UI or emit validation state
}
}
Problems:
Solution: Use combineLatest() for cleaner, composable validation.
// ❌ Bad: Multiple consumers sharing same stream
let stream = AsyncStream<Int> { continuation in
for i in 1...10 {
continuation.yield(i)
}
continuation.finish()
}
Task {
for await value in stream {
print("Consumer 1: \(value)")
}
}
Task {
for await value in stream {
print("Consumer 2: \(value)")
}
}
Problem: Values are split between consumers unpredictably. Each value goes to only one consumer.
Solution: Use AsyncChannel for true multi-consumer scenarios with backpressure.
Swift 6.2 introduces typed, thread-safe notifications.
For notifications that should be delivered on the main actor:
// Old way
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleDidBecomeActive() // ⚠️ Concurrency warning
}
// New way (iOS 26+)
token = NotificationCenter.default.addObserver(
of: UIApplication.self,
for: .didBecomeActive
) { [weak self] message in
self?.handleDidBecomeActive() // ✅ No warning, guaranteed main actor
}
Key difference: Observer closure is guaranteed to run on @MainActor.
For notifications delivered asynchronously on arbitrary isolation:
struct RecentBuildsChangedMessage: NotificationCenter.AsyncMessage {
typealias Subject = [RecentBuild]
let recentBuilds: Subject
}
// Enable static member lookup
extension NotificationCenter.MessageIdentifier
where Self == NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> {
static var recentBuildsChanged: NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> {
.init()
}
}
Posting:
let builds = [RecentBuild(appName: "Stock Analyzer")]
let message = RecentBuildsChangedMessage(recentBuilds: builds)
NotificationCenter.default.post(message)
Observing:
// Old way: Unsafe casting
NotificationCenter.default.addObserver(forName: .recentBuildsChanged, object: nil, queue: nil) { notification in
guard let builds = notification.object as? [RecentBuild] else { return }
handleBuilds(builds)
}
// New way: Strongly typed, thread-safe
token = NotificationCenter.default.addObserver(
of: [RecentBuild].self,
for: .recentBuildsChanged
) { message in
handleBuilds(message.recentBuilds) // ✅ Direct access, no casting
}
Benefits:
Any casting)Course Deep Dive: This topic is covered in detail in Lesson 12.10: Migrating to concurrency-safe notifications
Break it down:
Start small:
Sendable by defaultOptions:
@preconcurrency temporarilyThis is the "concurrency rabbit hole":
Course Deep Dive: This topic is covered in detail in Lesson 12.11: Frequently Asked Questions (FAQ) around Swift 6 Migrations
@MainActor: Do not slap @MainActor on everything to silence errors. Ask whether the code truly needs main-actor isolation.@unchecked Sendable as a first response: Prefer immutable value types or actors. Reserve escape hatches for documented, temporary exceptions.nonisolated async behavior depends on whether NonisolatedNonsendingByDefault is enabled.Migration to Swift 6 is a journey, not a sprint:
swift package migrateThe result is compile-time thread safety, more maintainable code, and a future-proof codebase.
Additional resources: