Swift Concurrency skill for Claude Code
Reach Swift 6 mode with zero data-race warnings. Claude migrates Swift code to strict concurrency one module at a time, reading each isolation diagnostic for its real cause instead of reaching for @unchecked Sendable. It isolates UI models to MainActor, puts shared state in actors or a Mutex, replaces completion handlers with checked continuations, and understands how Swift 6.2 default isolation changes the rules.
- name
swift-concurrency - size211 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Migrates and writes Swift 6 strict-concurrency code — async/await, actors, MainActor isolation, Sendable conformance, sending parameters, structured tasks with async let and task groups, cancellation, and bridging callbacks with continuations, including Swift 6.2 approachable concurrency settings. Use when Xcode shows Sendable or actor-isolation errors, when moving to the Swift 6 language mode, replacing GCD or completion handlers, or debugging data races and UI updates off the main thread. Try asking
- Xcode shows 80 Sendable warnings after enabling strict concurrency — work through them
- Convert this completion-handler networking layer to async/await
- Is this actor-based image cache safe given reentrancy?
Use it when
- Turning on complete concurrency checking or the Swift 6 language mode
- Seeing MainActor, Sendable, or sending diagnostics
- Replacing GCD queues or completion handlers with async/await
- Designing shared mutable state across tasks
Skip it when
- Building SwiftUI view layout and navigation — use swiftui-views
- Profiling app launch, scrolling, or memory on device — use mobile-performance
How to check the result
The module builds in Swift 6 mode with no concurrency warnings, every unchecked or unsafe annotation has a written reason, and tests pass under Thread Sanitizer.
The common mistake it prevents
Marking a mutable class @unchecked Sendable to make errors disappear, which hides a real data race from the compiler permanently.
Done well means: the target builds in the Swift 6 language mode (or with complete
strict-concurrency checking) with zero warnings, UI state is MainActor-isolated, shared
mutable state lives in an actor or behind a Mutex, every Task {} has a reason and a
cancellation story, and @unchecked Sendable appears only with a written justification.
Ground rules
- Fix isolation, do not silence it.
nonisolated(unsafe),@unchecked Sendable, and@preconcurrencyare escape hatches for code you do not own or have proven safe. - Prefer structured concurrency (
async let,withTaskGroup) over unstructuredTask {}. Unstructured tasks do not inherit cancellation from the caller. - Value types (structs, enums with Sendable payloads) are the default way to cross isolation boundaries.
- Do not wrap everything in actors. An actor is for state shared by concurrent callers;
a view model touched only by UI is
@MainActor. - Never block a concurrency thread: no
DispatchSemaphore.wait,Thread.sleep, or synchronous network calls inside async code.
Inspect the configuration first
grep -nE "swift-tools-version|swiftLanguageMode|StrictConcurrency|enableUpcomingFeature|defaultIsolation" Package.swift
grep -nE "SWIFT_VERSION|SWIFT_STRICT_CONCURRENCY|SWIFT_DEFAULT_ACTOR_ISOLATION|SWIFT_APPROACHABLE_CONCURRENCY" *.xcodeproj/project.pbxproj
swift --version
| Setting | Meaning |
|---|---|
Swift 5 mode + SWIFT_STRICT_CONCURRENCY = complete | Data-race diagnostics as warnings; migration stage |
| Swift 6 language mode | Same diagnostics as errors |
| Default actor isolation = MainActor (Swift 6.2) | Unannotated code in the module is MainActor; good for app targets, not for libraries |
Approachable concurrency / NonisolatedNonsendingByDefault (6.2) | Nonisolated async functions run on the caller’s actor unless marked @concurrent |
Know which mode the module is in before interpreting any diagnostic; the same code behaves differently under 6.2’s defaults.
Migration workflow
- [ ] 1. Enable complete checking in Swift 5 mode, one module at a time, leaf modules first
- [ ] 2. Mark UI types (view models, coordinators, UIKit/AppKit subclasses) @MainActor
- [ ] 3. Make model/value types Sendable (usually automatic for structs of Sendable fields)
- [ ] 4. Convert shared mutable singletons/caches to actors or Mutex-protected classes
- [ ] 5. Replace completion handlers with async APIs; bridge legacy ones with continuations
- [ ] 6. Use @preconcurrency import only for third-party modules not yet annotated
- [ ] 7. Reach zero warnings, then flip the module to the Swift 6 language mode
- [ ] 8. Run tests with Thread Sanitizer enabled
Package.swift for a module in Swift 6 mode:
// swift-tools-version: 6.0
let package = Package(
name: "Core",
targets: [
.target(name: "Core"), // tools 6.0 defaults targets to Swift 6 mode
.target(name: "Legacy", swiftSettings: [
.swiftLanguageMode(.v5),
.enableUpcomingFeature("StrictConcurrency"), // warnings while migrating
]),
]
)
Diagnostic to fix
| Diagnostic (paraphrased) | Usual fix |
|---|---|
| Main actor-isolated property accessed from nonisolated context | Mark the caller @MainActor, or await MainActor.run {} at the boundary |
| Non-sendable type passed across actor boundary | Make it a struct / Sendable; or pass with sending; or copy the needed values |
Capture of non-sendable in @Sendable closure | Capture values, not the object; or isolate the closure to the actor that owns the object |
| Static property is not concurrency-safe | Make it let of a Sendable type, isolate it to @MainActor, or move it into an actor |
| Actor-isolated method cannot satisfy nonisolated protocol requirement | Isolated conformance (6.2, extension X: @MainActor P) or make the method nonisolated reading only immutable state |
| Sending value risks causing data races | The value is used after being sent; stop using it, or copy before sending |
Isolation patterns
@MainActor
@Observable
final class ProfileModel {
private(set) var state: LoadState<Profile> = .idle
private let client: ProfileClient // Sendable (struct or actor)
init(client: ProfileClient) { self.client = client }
func load(id: User.ID) async {
state = .loading
do {
let profile = try await client.fetch(id) // runs off main if client is nonisolated/@concurrent
state = .loaded(profile)
} catch is CancellationError {
// view went away; leave state alone
} catch {
state = .failed(error.localizedDescription)
}
}
}
actor ImageCache {
private var images: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let cached = images[url] { return cached }
if let task = inFlight[url] { return try await task.value } // dedupe concurrent loads
let task = Task { try await URLSession.shared.data(from: url).0 }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
images[url] = data
return data
}
}
Actor reentrancy: state can change across every await inside an actor. Re-check
invariants after awaiting, and record in-flight work (as above) instead of assuming
nothing happened in between.
For synchronous, fine-grained shared state, Mutex from the Synchronization module
(iOS 18 / macOS 15) avoids making callers async:
import Synchronization
final class Counter: Sendable {
private let value = Mutex(0)
func increment() -> Int { value.withLock { $0 += 1; return $0 } }
}
Structured tasks and cancellation
func loadDashboard() async throws -> Dashboard {
async let user = api.user()
async let feed = api.feed()
return try await Dashboard(user: user, feed: feed) // if one throws, the other is cancelled
}
func thumbnails(for urls: [URL]) async throws -> [URL: Image] {
try await withThrowingTaskGroup(of: (URL, Image).self) { group in
for url in urls.prefix(100) {
group.addTask { (url, try await render(url)) }
}
var result: [URL: Image] = [:]
for try await (url, image) in group { result[url] = image }
return result
}
}
- Bound concurrency in task groups: add the first N tasks, then add one more each time one completes.
- Cancellation is cooperative: call
try Task.checkCancellation()in loops; URLSession async APIs already honor it. - In SwiftUI use
.task { }/.task(id:); it cancels when the view disappears. AvoidTask {}inonAppear. - Store unstructured
Taskhandles you may need to cancel and cancel them indeinitor when the owner stops. Task.detachedalmost never; it drops priority, task-locals, and actor context.
Bridging legacy APIs
func currentLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locator.requestLocation { result in
continuation.resume(with: result) // resume exactly once on every path
}
}
}
- Resume exactly once. Missing a resume leaks the task forever; resuming twice crashes (checked continuation) or corrupts (unsafe).
- Delegate or multi-value callbacks become
AsyncStreamwithonTerminationcleanup. - Many Apple APIs already have async variants; check before writing a continuation.
Gotchas
@MainActoron a class does not make itsnonisolatedorTask.detachedcode run on main.Task {}created inside a@MainActormethod inherits MainActor; heavy work inside it blocks the UI. Move the work into a nonisolated or@concurrentasync function.- Under Swift 6.2 approachable concurrency, a plain nonisolated
asyncfunction runs on the caller’s actor; mark CPU-heavy functions@concurrentto leave the main actor. @unchecked Sendableon a class with mutable state and no lock is a data race the compiler can no longer report.- Global
letconstants of non-Sendable class types are errors; globalvaralways is. - Combine
sinkand NotificationCenter closures run on whatever thread posts; hop withreceive(on:)or use the async sequence variants.
Verification
- Build with Swift 6 language mode (or complete checking): zero concurrency warnings.
grep -rn "@unchecked Sendable\|nonisolated(unsafe)\|@preconcurrency" Sources— each hit has a comment explaining why it is safe.- Run the test suite with Thread Sanitizer (
swift test --sanitize=thread, or the Xcode scheme diagnostic) and the Main Thread Checker enabled. - Manually exercise cancel paths: leave a screen mid-load and confirm no state update or crash happens afterwards.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.