If you've been writing Swift concurrency code over the past couple of years, you've probably experienced the frustration firsthand. Swift 6.0 dropped strict concurrency checking on us and suddenly — boom — an avalanche of compiler errors. Sendable conformances everywhere, actor isolation warnings you didn't ask for, and a general feeling that the concurrency model was fighting you instead of helping you.
Swift 6.2 changes all of that. Seriously.
The set of improvements is collectively called Approachable Concurrency, and the philosophy is refreshingly simple: you should only need to understand as much concurrency as you actually use. In this guide, we'll walk through every major change, show you practical code examples, and give you a clear migration path. Whether you're starting fresh or upgrading from Swift 6.0/6.1, this one's for you.
The Philosophy Behind Approachable Concurrency
Before we get into the technical details, it's worth understanding the thinking here. The Swift team recognized that concurrency adoption was creating a "cliff" — developers writing simple, single-threaded code were being forced to deal with actors, Sendable conformances, and isolation boundaries even when they had zero intention of writing concurrent code. That's not great.
Approachable Concurrency introduces progressive disclosure into the concurrency model. Think of it in three phases:
- Phase 1 — Sequential code: Simple, single-threaded code should compile without concurrency errors. You shouldn't need to know about actors or Sendable just to write a straightforward app.
- Phase 2 — Async code: When you start using async/await, the compiler should help you write correct code without drowning you in data-race safety warnings.
- Phase 3 — Parallel code: Only when you intentionally introduce parallelism (task groups, detached tasks, multiple actors) should you need to think deeply about isolation boundaries and sendability.
This philosophy shapes every single change we'll discuss below.
Default Actor Isolation with SE-0466
This is probably the most impactful change in Swift 6.2. SE-0466: Control Default Actor Isolation Inference introduces a module-level setting that makes @MainActor the default isolation for all declarations in your module.
The Problem It Solves
In Swift 6.0 and 6.1, code without explicit isolation annotations was considered nonisolated by default. So a simple struct or class with no concurrency intentions could trigger data-race warnings when accessed from a main actor context — say, from a SwiftUI view. Developers ended up sprinkling @MainActor annotations across their entire codebase just to make the compiler happy. Honestly, it was exhausting.
How It Works
With SE-0466, you can set a default isolation domain for your entire module. When you choose MainActor as the default, every declaration in that module — structs, classes, functions, properties — is implicitly isolated to the main actor unless you explicitly mark it otherwise.
For Swift packages, you enable this in your Package.swift:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyApp",
products: [
.library(name: "MyApp", targets: ["MyApp"]),
],
targets: [
.target(
name: "MyApp",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
),
]
)
In Xcode 26, newly created projects have this enabled by default. For existing projects, you can flip it on in your target's build settings under Swift Compiler — Default Actor Isolation.
Practical Example
Consider a typical data controller class. Before Swift 6.2, you needed explicit annotations everywhere:
// Swift 6.0/6.1 — explicit @MainActor required
@MainActor
class DataController: ObservableObject {
@Published var items: [Item] = []
func load() {
// fetch and update items
}
func save() {
// persist items
}
}
@MainActor
struct ContentView: View {
@StateObject var controller = DataController()
var body: some View {
List(controller.items) { item in
Text(item.name)
}
.task {
controller.load()
}
}
}
With default MainActor isolation in Swift 6.2, those @MainActor annotations just vanish:
// Swift 6.2 with defaultIsolation(MainActor.self)
// No @MainActor needed — it's the default!
class DataController: ObservableObject {
@Published var items: [Item] = []
func load() {
// fetch and update items
}
func save() {
// persist items
}
}
struct ContentView: View {
@StateObject var controller = DataController()
var body: some View {
List(controller.items) { item in
Text(item.name)
}
.task {
controller.load()
}
}
}
Much cleaner, right?
Important Caveats
- Default isolation applies per module only. External dependencies aren't affected.
- You can opt out for specific declarations using
nonisolated. - Network and file I/O calls work normally — the system frameworks handle threading internally.
- This is opt-in. It won't break existing projects when you update to Swift 6.2.
Nonisolated Async Functions and SE-0461
The second major change is SE-0461: Run Nonisolated Async Functions on the Caller's Actor by Default. This might be the most subtle yet important change in Swift 6.2 — and honestly, it's the one that eliminates the most head-scratching bugs.
The Old Behavior
In Swift 6.0 and 6.1, a nonisolated async function could run on any executor. When called from the main actor, the function might hop to a background thread, creating an implicit isolation boundary. This led to two major headaches:
- Sendable errors: Passing non-Sendable values across the implicit boundary triggered compiler errors that felt completely random.
- Unexpected threading: You'd expect an async function to behave like its synchronous counterpart, but it'd end up running on a different thread entirely.
// Swift 6.0/6.1 — this could fail with Sendable errors
class NetworkingClient {
func loadPhotos() async throws -> [Photo] {
// This runs on a background thread, not the caller's actor
let data = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Photo].self, from: data.0)
}
}
@MainActor
struct PhotoView: View {
let client = NetworkingClient()
var body: some View {
Text("Photos")
.task {
// Error: sending non-Sendable 'client' across actor boundary
let photos = try? await client.loadPhotos()
}
}
}
The New Behavior
In Swift 6.2, nonisolated async functions inherit the caller's isolation context by default. They run on the same actor as their caller. The technical term for this new default is nonisolated(nonsending).
That same code from above? It just works in Swift 6.2, because loadPhotos() runs on the main actor when called from a @MainActor context. No changes needed.
Understanding nonisolated(nonsending)
The nonisolated(nonsending) attribute tells the compiler that a function doesn't cross an isolation boundary when called. It inherits whatever isolation context the caller has. You can use it explicitly, though in Swift 6.2 it's the default for async functions:
class DataProcessor {
// Explicit nonisolated(nonsending) — runs on caller's actor
nonisolated(nonsending) func process() async -> Result {
// If called from @MainActor, runs on main thread
// If called from a custom actor, runs on that actor
let processed = await heavyComputation()
return processed
}
}
Enabling This Feature
To enable this behavior as an upcoming feature (before it becomes the default in a future language mode), add the feature flag to your package:
.target(
name: "MyTarget",
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault")
]
)
The @concurrent Attribute
So, with nonisolated async functions now running on the caller's actor by default, what happens when you actually want something to run in the background? That's where the new @concurrent attribute comes in.
When to Use @concurrent
Use @concurrent when a function performs CPU-intensive work that shouldn't block the caller's actor — think image processing, parsing large JSON payloads, or heavy computations:
class ImageProcessor {
// Runs on a background thread, NOT the caller's actor
@concurrent
nonisolated func applyFilters(to image: UIImage) async -> UIImage {
// Heavy image processing — don't block the main thread
let ciImage = CIImage(image: image)!
let filtered = ciImage
.applyingFilter("CIGaussianBlur", parameters: ["inputRadius": 10])
.applyingFilter("CIColorControls", parameters: ["inputSaturation": 1.5])
let context = CIContext()
let cgImage = context.createCGImage(filtered, from: filtered.extent)!
return UIImage(cgImage: cgImage)
}
}
@MainActor
class PhotoEditor: ObservableObject {
@Published var processedImage: UIImage?
let processor = ImageProcessor()
func editPhoto(_ original: UIImage) async {
// applyFilters runs on background thread thanks to @concurrent
// The UI stays responsive
processedImage = await processor.applyFilters(to: original)
}
}
The Decision Framework
Here's a simple way to think about it:
- Use the default (nonisolated nonsending): When the function coordinates with actor state, does quick operations, or should logically run in the caller's context.
- Use @concurrent: When the function does heavy computation, processes large data sets, or does work that absolutely shouldn't block the main thread.
A good rule of thumb: if the function body is mostly await calls to system APIs (networking, file I/O), let it inherit the caller's isolation. If it's dominated by CPU-bound synchronous work, mark it @concurrent.
Global-Actor Isolated Conformances with SE-0470
This one had been bugging me for a while. SE-0470: Global-Actor Isolated Conformances addresses a long-standing frustration where @MainActor types couldn't cleanly conform to protocols like Equatable, Hashable, or CustomStringConvertible.
The Problem
In Swift 6.0, a @MainActor class conforming to Equatable created this annoying dilemma. The == operator needed to be nonisolated (because Equatable doesn't know about actors), but making it nonisolated meant it couldn't safely access actor-isolated properties:
// Swift 6.0 — awkward workarounds needed
@MainActor
class User {
var id: UUID
var name: String
// Had to use nonisolated and hope for the best,
// or use @unchecked Sendable
nonisolated static func == (lhs: User, rhs: User) -> Bool {
// Cannot safely access lhs.id or lhs.name here!
return lhs.id == rhs.id // Compiler warning
}
}
The Solution
With SE-0470, you can declare that a protocol conformance is isolated to a specific global actor. It's elegant:
// Swift 6.2 — clean and safe
@MainActor
class User: @MainActor Equatable, @MainActor Hashable {
var id: UUID
var name: String
init(id: UUID, name: String) {
self.id = id
self.name = name
}
static func == (lhs: User, rhs: User) -> Bool {
// Safe! This conformance only works from @MainActor context
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
The @MainActor Equatable syntax tells the compiler this conformance is only valid when called from the main actor's context. The compiler enforces this at call sites, preventing any unsafe cross-boundary access.
Isolated Synchronous Deinit with SE-0371
This one's a bit niche, but if you've ever tried to clean up actor-isolated state during deinitialization, you know the pain. SE-0371: Isolated Synchronous Deinit finally solves it.
@MainActor
class ResourceManager {
var handles: [FileHandle] = []
var observers: [NSObjectProtocol] = []
func cleanUp() {
// Close file handles, remove observers, etc.
handles.forEach { $0.closeFile() }
observers.forEach { NotificationCenter.default.removeObserver($0) }
}
// New in Swift 6.2 — isolated deinit
isolated deinit {
// Can safely access @MainActor properties and call @MainActor methods
cleanUp()
}
}
Previously, deinit was always nonisolated, which meant you couldn't safely touch actor-isolated state. The isolated deinit syntax guarantees the deinitializer runs on the correct actor. Simple, but a huge quality-of-life improvement.
Task.immediate with SE-0472
Here's a subtle one that'll save you from some tricky bugs. SE-0472: Starting Tasks Synchronously introduces Task.immediate, which addresses a timing issue with standard Task { } initializers that's bitten a lot of developers.
The Timing Problem
When you create a task with Task { }, the closure gets enqueued and runs later — even if you're already on the correct executor. This can cause unexpected ordering:
@MainActor
func updateUI() {
print("1. Before task")
Task {
print("3. Inside task") // Runs LATER, not immediately
}
print("2. After task creation")
}
// Output: 1, 2, 3
The Solution
Task.immediate starts executing synchronously on the current executor until the first suspension point:
@MainActor
func updateUI() {
print("1. Before task")
Task.immediate {
print("2. Inside task — runs NOW") // Runs immediately
await someAsyncWork() // Suspends here
print("4. After suspension")
}
print("3. After task creation")
}
// Output: 1, 2, 3, 4
This is especially handy in SwiftUI views and view models where you need deterministic execution order:
@MainActor
class ViewModel: ObservableObject {
@Published var isLoading = false
@Published var data: [Item] = []
func refresh() {
Task.immediate {
isLoading = true // Happens immediately — UI updates right away
data = await fetchItems()
isLoading = false
}
}
}
Task Naming with SE-0469
Debugging concurrent code is rough. We all know it. SE-0469: Task Naming makes it a little less painful by letting you name your tasks so they're identifiable in debuggers and logs:
func fetchDashboardData() async {
await withTaskGroup(of: Void.self) { group in
group.addTask(name: "FetchUserProfile") {
await self.loadUserProfile()
}
group.addTask(name: "FetchRecentOrders") {
await self.loadRecentOrders()
}
group.addTask(name: "FetchRecommendations") {
await self.loadRecommendations()
}
}
}
// Access the current task name for logging
func logCurrentTask() {
let name = Task.name ?? "unnamed"
print("[\(name)] Performing operation...")
}
Task names show up in Xcode's debug navigator and Instruments. It's one of those small things that makes a huge difference when you're trying to figure out which task is doing what during a debugging session.
Task Priority Escalation with SE-0462
SE-0462 introduces APIs for detecting and responding to task priority changes at runtime. This is particularly useful for long-running operations that need to adapt when the system (or your code) decides their work has become more urgent:
func processLargeDataset(_ dataset: [Record]) async throws -> ProcessedData {
let task = Task(priority: .background, name: "DatasetProcessing") {
try await withTaskPriorityEscalationHandler {
// Process records in batches
var results: [ProcessedRecord] = []
for batch in dataset.chunked(into: 100) {
try Task.checkCancellation()
let processed = await processBatch(batch)
results.append(contentsOf: processed)
}
return ProcessedData(records: results)
} onPriorityEscalated: { oldPriority, newPriority in
print("Priority escalated from \(oldPriority) to \(newPriority)")
// Could adjust batch size, allocate more resources, etc.
}
}
// Later, if user is waiting on results:
task.escalatePriority(to: .userInitiated)
return try await task.value
}
Worth noting: priority can only be escalated (increased), never decreased. This makes sense — high-priority work should always get the resources it needs.
Weak Let with SE-0481
Okay, SE-0481: weak let isn't strictly a concurrency feature, but it has real implications for concurrent code. It lets you declare immutable weak references, which in turn enables Sendable conformance for types with weak properties:
// Before Swift 6.2 — weak var prevented Sendable conformance
final class SessionManager {
weak var delegate: SessionDelegate? // var = mutable = not Sendable
}
// Swift 6.2 — weak let enables Sendable
final class SessionManager: Sendable {
weak let delegate: SessionDelegate? // let = immutable = Sendable-compatible
init(delegate: SessionDelegate?) {
self.delegate = delegate
}
}
The weak let property can't be reassigned after initialization, but the referenced object can still be deallocated (setting the value to nil). This combination of immutability and weak semantics is exactly what the Sendable checker needs.
Enabling Approachable Concurrency: The Complete Setup
Approachable Concurrency isn't a single feature — it's a build setting that bundles five upcoming features together. Here's how to enable the full suite.
In Xcode 26
Navigate to your target's build settings and look for "Approachable Concurrency". Set it to Yes and you get all five features at once. Easy.
In Package.swift
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyLibrary",
targets: [
.target(
name: "MyLibrary",
swiftSettings: [
// Enable all five Approachable Concurrency features
.enableUpcomingFeature("DisableOutwardActorInference"),
.enableUpcomingFeature("GlobalActorIsolatedTypesUsability"),
.enableUpcomingFeature("InferIsolatedConformances"),
.enableUpcomingFeature("InferSendableFromCaptures"),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
// Optionally, also set default isolation
.defaultIsolation(MainActor.self)
]
),
]
)
What Each Feature Does
- DisableOutwardActorInference (SE-0401): Property wrappers with global actor isolation no longer automatically apply that isolation to entire types. Makes isolation explicit and predictable.
- GlobalActorIsolatedTypesUsability (SE-0434): Removes unnecessary restrictions on
@MainActortypes — easier property access, automatic@Sendableinference, and safe capture of non-Sendable values in isolated closures. - InferIsolatedConformances (SE-0470): Enables the global-actor isolated protocol conformances we discussed earlier.
- InferSendableFromCaptures (SE-0418): The compiler automatically infers
@Sendablefor method references and key paths when safe. Less boilerplate, more clarity. - NonisolatedNonsendingByDefault (SE-0461): Nonisolated async functions run on the caller's actor by default.
Migration Guide: From Swift 6.0/6.1 to 6.2
Migrating to Swift 6.2's Approachable Concurrency takes some care, especially around NonisolatedNonsendingByDefault. Here's a step-by-step approach that's worked well for me.
Step 1: Update Your Toolchain
Make sure you're using Xcode 26 or the Swift 6.2 toolchain. Update your Package.swift tools version to 6.2.
Step 2: Enable Features Incrementally
Don't enable all five features at once. Seriously, don't. Start with the least disruptive ones:
- DisableOutwardActorInference — Usually requires minimal changes.
- GlobalActorIsolatedTypesUsability — Should actually reduce compiler errors, not increase them.
- InferSendableFromCaptures — Eliminates some manual annotations you were writing by hand.
- InferIsolatedConformances — May require reviewing protocol conformances.
- NonisolatedNonsendingByDefault — The most impactful one. Save it for last.
Step 3: Audit Your Async Functions
Before enabling NonisolatedNonsendingByDefault, go through your async functions and identify which ones should run on a background thread. Mark those with @concurrent:
// Audit: does this function do heavy work?
// YES — add @concurrent
@concurrent
nonisolated func parseJSON(_ data: Data) async throws -> [Model] {
return try JSONDecoder().decode([Model].self, from: data)
}
// NO — leave as default (will inherit caller's actor)
func fetchUser(id: UUID) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: userURL(id))
return try JSONDecoder().decode(User.self, from: data)
}
Step 4: Consider Default Isolation
For app targets and UI-heavy packages, enable .defaultIsolation(MainActor.self). For utility libraries and backend packages, you'll probably want to skip this and keep the default nonisolated behavior.
Step 5: Remove Unnecessary Annotations
After enabling the new features, do a cleanup pass. Remove explicit @MainActor annotations that are now redundant (if you're using default isolation), strip out manual @Sendable annotations that the compiler now infers, and simplify protocol conformances using the isolated conformance syntax. Your code will thank you.
Real-World Example: Building a Photo Gallery App
Let's put everything together with a real-world example that shows multiple Swift 6.2 features working in harmony:
// Package uses: defaultIsolation(MainActor.self) + all Approachable Concurrency features
// No @MainActor needed — it's the default
class PhotoGalleryViewModel: ObservableObject {
@Published var photos: [Photo] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let api = PhotoAPI()
private let processor = ImageProcessor()
func loadGallery() {
// Task.immediate ensures isLoading is set before the view updates
Task.immediate(name: "LoadGallery") {
isLoading = true
errorMessage = nil
do {
// fetchPhotos inherits MainActor isolation — no Sendable issues
let rawPhotos = try await api.fetchPhotos()
// Process thumbnails in parallel on background threads
photos = await withTaskGroup(of: Photo.self) { group in
for photo in rawPhotos {
group.addTask(name: "Thumbnail-\(photo.id)") {
var processed = photo
// generateThumbnail is @concurrent — runs on background
processed.thumbnail = await self.processor
.generateThumbnail(for: photo.fullImage)
return processed
}
}
var results: [Photo] = []
for await photo in group {
results.append(photo)
}
return results
}
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
}
class PhotoAPI {
// Inherits caller's actor — no Sendable errors
func fetchPhotos() async throws -> [Photo] {
let (data, _) = try await URLSession.shared.data(from: photosURL)
return try JSONDecoder().decode([Photo].self, from: data)
}
}
class ImageProcessor {
// Explicitly runs on background thread — heavy CPU work
@concurrent
nonisolated func generateThumbnail(for image: UIImage) async -> UIImage {
let size = CGSize(width: 150, height: 150)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
}
struct PhotoGalleryView: View {
@StateObject var viewModel = PhotoGalleryViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading photos...")
} else if let error = viewModel.errorMessage {
ContentUnavailableView(
"Error Loading Photos",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
ForEach(viewModel.photos) { photo in
Image(uiImage: photo.thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 150, height: 150)
.clipped()
.cornerRadius(8)
}
}
.padding()
}
}
.navigationTitle("Gallery")
}
.task {
viewModel.loadGallery()
}
}
}
Look at how clean this is compared to what it would have been in Swift 6.0. No @MainActor annotations on the view model or view, no @Sendable closures, no contorted workarounds for passing non-Sendable types across boundaries. The only explicit concurrency annotation is @concurrent on the image processor — exactly where we want parallelism. That's the beauty of it.
Common Pitfalls and How to Avoid Them
Pitfall 1: Accidentally Blocking the Main Thread
With default MainActor isolation and nonisolated(nonsending), more code runs on the main thread than before. Keep an eye out for CPU-heavy work that should be @concurrent:
// BAD — this now runs on the main thread by default!
func processAllRecords() async -> [ProcessedRecord] {
return records.map { record in
// Heavy computation blocking the main thread
expensiveTransform(record)
}
}
// GOOD — mark it @concurrent
@concurrent
nonisolated func processAllRecords() async -> [ProcessedRecord] {
return records.map { record in
expensiveTransform(record)
}
}
Pitfall 2: Enabling NonisolatedNonsendingByDefault Without Auditing
This is a big one. Enabling this feature changes runtime behavior, not just compile-time checking. Functions that used to run on background threads will now run on the caller's actor. Always audit your async functions before flipping this switch.
Pitfall 3: Using @unchecked Sendable as a Crutch
With Approachable Concurrency, the need for @unchecked Sendable drops dramatically. If you find yourself reaching for it, take a step back. One of the new features — isolated conformances, inferred Sendable, nonisolated(nonsending) — probably solves your problem the right way.
What This Means for the Swift Ecosystem
Swift 6.2's Approachable Concurrency is more than a collection of improvements — it represents a fundamental rethinking of how developers interact with the concurrency system. By making the common case easy and only requiring expertise when parallelism is explicitly introduced, Swift is positioning itself as a language that's both safe and ergonomic.
For library authors, the key takeaway is to adopt these features incrementally and think carefully about which functions genuinely need @concurrent. For app developers, the combination of default MainActor isolation and nonisolated(nonsending) eliminates the vast majority of friction points that made Swift 6.0 adoption so painful.
The future of Swift concurrency isn't about writing more annotations — it's about writing fewer. And with Swift 6.2, that future is here.