Swift 6.2 Approachable Concurrency
Patterns for adopting Swift 6.2's concurrency model where code runs single-threaded by default and concurrency is introduced explicitly. Eliminates common data-race errors without sacrificing performance.
When to Activate
- Migrating Swift 5.x or 6.0/6.1 projects to Swift 6.2
- Resolving data-race safety compiler errors
- Designing MainActor-based app architecture
- Offloading CPU-intensive work to background threads
- Implementing protocol conformances on MainActor-isolated types
- Enabling Approachable Concurrency build settings in Xcode 26
Core Problem: Implicit Background Offloading
In Swift 6.1 and earlier, async functions could be implicitly offloaded to background threads, causing data-race errors even in seemingly safe code:
swift
1// Swift 6.1: ERROR
2@MainActor
3final class StickerModel {
4 let photoProcessor = PhotoProcessor()
5
6 func extractSticker(_ item: PhotosPickerItem) async throws -> Sticker? {
7 guard let data = try await item.loadTransferable(type: Data.self) else { return nil }
8
9 // Error: Sending 'self.photoProcessor' risks causing data races
10 return await photoProcessor.extractSticker(data: data, with: item.itemIdentifier)
11 }
12}
Swift 6.2 fixes this: async functions stay on the calling actor by default.
swift
1// Swift 6.2: OK — async stays on MainActor, no data race
2@MainActor
3final class StickerModel {
4 let photoProcessor = PhotoProcessor()
5
6 func extractSticker(_ item: PhotosPickerItem) async throws -> Sticker? {
7 guard let data = try await item.loadTransferable(type: Data.self) else { return nil }
8 return await photoProcessor.extractSticker(data: data, with: item.itemIdentifier)
9 }
10}
MainActor types can now conform to non-isolated protocols safely:
swift
1protocol Exportable {
2 func export()
3}
4
5// Swift 6.1: ERROR — crosses into main actor-isolated code
6// Swift 6.2: OK with isolated conformance
7extension StickerModel: @MainActor Exportable {
8 func export() {
9 photoProcessor.exportAsPNG()
10 }
11}
The compiler ensures the conformance is only used on the main actor:
swift
1// OK — ImageExporter is also @MainActor
2@MainActor
3struct ImageExporter {
4 var items: [any Exportable]
5
6 mutating func add(_ item: StickerModel) {
7 items.append(item) // Safe: same actor isolation
8 }
9}
10
11// ERROR — nonisolated context can't use MainActor conformance
12nonisolated struct ImageExporter {
13 var items: [any Exportable]
14
15 mutating func add(_ item: StickerModel) {
16 items.append(item) // Error: Main actor-isolated conformance cannot be used here
17 }
18}
Core Pattern — Global and Static Variables
Protect global/static state with MainActor:
swift
1// Swift 6.1: ERROR — non-Sendable type may have shared mutable state
2final class StickerLibrary {
3 static let shared: StickerLibrary = .init() // Error
4}
5
6// Fix: Annotate with @MainActor
7@MainActor
8final class StickerLibrary {
9 static let shared: StickerLibrary = .init() // OK
10}
MainActor Default Inference Mode
Swift 6.2 introduces a mode where MainActor is inferred by default — no manual annotations needed:
swift
1// With MainActor default inference enabled:
2final class StickerLibrary {
3 static let shared: StickerLibrary = .init() // Implicitly @MainActor
4}
5
6final class StickerModel {
7 let photoProcessor: PhotoProcessor
8 var selection: [PhotosPickerItem] // Implicitly @MainActor
9}
10
11extension StickerModel: Exportable { // Implicitly @MainActor conformance
12 func export() {
13 photoProcessor.exportAsPNG()
14 }
15}
This mode is opt-in and recommended for apps, scripts, and other executable targets.
Core Pattern — @concurrent for Background Work
When you need actual parallelism, explicitly offload with @concurrent:
Important: This example requires Approachable Concurrency build settings — SE-0466 (MainActor default isolation) and SE-0461 (NonisolatedNonsendingByDefault). With these enabled, extractSticker stays on the caller's actor, making mutable state access safe. Without these settings, this code has a data race — the compiler will flag it.
swift
1nonisolated final class PhotoProcessor {
2 private var cachedStickers: [String: Sticker] = [:]
3
4 func extractSticker(data: Data, with id: String) async -> Sticker {
5 if let sticker = cachedStickers[id] {
6 return sticker
7 }
8
9 let sticker = await Self.extractSubject(from: data)
10 cachedStickers[id] = sticker
11 return sticker
12 }
13
14 // Offload expensive work to concurrent thread pool
15 @concurrent
16 static func extractSubject(from data: Data) async -> Sticker { /* ... */ }
17}
18
19// Callers must await
20let processor = PhotoProcessor()
21processedPhotos[item.id] = await processor.extractSticker(data: data, with: item.id)
To use @concurrent:
- Mark the containing type as
nonisolated
- Add
@concurrent to the function
- Add
async if not already asynchronous
- Add
await at call sites
Key Design Decisions
| Decision | Rationale |
|---|
| Single-threaded by default | Most natural code is data-race free; concurrency is opt-in |
| Async stays on calling actor | Eliminates implicit offloading that caused data-race errors |
| Isolated conformances | MainActor types can conform to protocols without unsafe workarounds |
@concurrent explicit opt-in | Background execution is a deliberate performance choice, not accidental |
| MainActor default inference | Reduces boilerplate @MainActor annotations for app targets |
| Opt-in adoption | Non-breaking migration path — enable features incrementally |
Migration Steps
- Enable in Xcode: Swift Compiler > Concurrency section in Build Settings
- Enable in SPM: Use
SwiftSettings API in package manifest
- Use migration tooling: Automatic code changes via swift.org/migration
- Start with MainActor defaults: Enable inference mode for app targets
- Add
@concurrent where needed: Profile first, then offload hot paths
- Test thoroughly: Data-race issues become compile-time errors
Best Practices
- Start on MainActor — write single-threaded code first, optimize later
- Use
@concurrent only for CPU-intensive work — image processing, compression, complex computation
- Enable MainActor inference mode for app targets that are mostly single-threaded
- Profile before offloading — use Instruments to find actual bottlenecks
- Protect globals with MainActor — global/static mutable state needs actor isolation
- Use isolated conformances instead of
nonisolated workarounds or @Sendable wrappers
- Migrate incrementally — enable features one at a time in build settings
Anti-Patterns to Avoid
- Applying
@concurrent to every async function (most don't need background execution)
- Using
nonisolated to suppress compiler errors without understanding isolation
- Keeping legacy
DispatchQueue patterns when actors provide the same safety
- Skipping
model.availability checks in concurrency-related Foundation Models code
- Fighting the compiler — if it reports a data race, the code has a real concurrency issue
- Assuming all async code runs in the background (Swift 6.2 default: stays on calling actor)
When to Use
- All new Swift 6.2+ projects (Approachable Concurrency is the recommended default)
- Migrating existing apps from Swift 5.x or 6.0/6.1 concurrency
- Resolving data-race safety compiler errors during Xcode 26 adoption
- Building MainActor-centric app architectures (most UI apps)
- Performance optimization — offloading specific heavy computations to background