Migration to Swift 6 and Strict Concurrency
Use this when:
- You are moving an existing codebase toward Swift 6 or stricter concurrency checking.
- Compiler diagnostics depend on language mode, default isolation, or upcoming features.
- You need the smallest safe migration sequence instead of a full architectural rewrite.
Skip this file if:
- You already know the exact diagnostic and only need a local fix. Start from
actors.md,sendable.md, orthreading.md. - You are looking for debounce, stream composition, or FRP operator replacements. Use
async-algorithms.md.
Jump to:
- Project Settings
- Six Migration Habits
- Step-by-Step Migration
- Migration Tooling
- Rewriting Closures to Async/Await
- Migrating from Combine/RxSwift
- Concurrency-Safe Notifications (iOS 26+)
- Anti-Patterns
Why Migrate to Swift 6?
Swift 6 doesn't fundamentally change how Swift Concurrency works—it enforces existing rules more strictly:
- Compile-time safety: Catches data races and threading issues at compile time instead of runtime
- Warnings become errors: Many Swift 5 warnings become hard errors in Swift 6 language mode
- Future-proofing: New concurrency features will build on this stricter foundation
- Better maintainability: Code becomes safer and easier to reason about
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
Project Settings That Change Concurrency Behavior
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.
Quick matrix
| 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. |
The Concurrency Rabbit Hole
A common migration experience:
- Enable strict concurrency checking
- See 50+ errors and warnings
- Fix a bunch of them
- Rebuild and see 80+ new errors appear
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
Six Migration Habits for Success
1. Don't Panic—It's All About Iterations
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 = MinimalAllow yourself 30 minutes per day to migrate gradually. Don't expect completion in a few days for large projects.
2. Sendable by Default for New Code
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.
3. Use Swift 6 for New Projects and Packages
For new projects, packages, or files:
- Enable Swift 6 language mode from the start
- Use Swift Concurrency features (async/await, actors)
- Reduce technical debt before it accumulates
You can enable Swift 6 for individual files in a Swift 5 project to prevent scope creep.
4. Resist the Urge to Refactor
Focus solely on concurrency changes. Don't combine migration with:
- Architecture refactors
- API modernization
- Code style improvements
Create separate tickets for non-concurrency refactors and address them later.
5. Focus on Minimal Changes
- Make small, focused pull requests
- Migrate one class or module at a time
- Get changes merged quickly to create checkpoints
- Avoid large PRs that are hard to review
6. Don't Just @MainActor All the Things
Don't blindly add @MainActor to fix warnings. Consider:
- Should this actually run on the main actor?
- Would a custom actor be more appropriate?
- Is
nonisolatedthe 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
Step-by-Step Migration Process
1. Find an Isolated Piece of Code
Start with:
- Standalone packages with minimal dependencies
- Individual Swift files within a package
- Code that's not heavily used throughout the project
Why: Fewer dependencies = less risk of falling into the concurrency rabbit hole.
2. Update Related Dependencies
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.
3. Add Async Alternatives
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:
- Colleagues can start using async/await immediately
- You can migrate callers before rewriting implementation
- Tests can be updated to async/await first
Tip: Use Xcode's Refactor → Add Async Wrapper to generate these automatically.
4. Change Default Actor Isolation (Swift 6.2+)
For app projects, set default isolation to @MainActor:
Xcode Build Settings:
Swift Concurrency → Default Actor Isolation = MainActorSwift Package Manager:
.target(
name: "MyTarget",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)This drastically reduces warnings in app code where most types need main thread access.
5. Enable Strict Concurrency Checking
Xcode Build Settings: Search for "Strict Concurrency Checking"
Three levels available:
- Minimal: Only checks code that explicitly adopts concurrency (
@Sendable,@MainActor) - Targeted: Checks all code that adopts concurrency, including
Sendableconformances - Complete: Checks entire codebase (matches Swift 6 behavior)
Swift Package Manager:
.target(
name: "MyTarget",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency=targeted")
]
)Strategy: Start with Minimal → Targeted → Complete, fixing errors at each level.
6. Add Sendable Conformances
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.
7. Enable Approachable Concurrency (Swift 6.2+)
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)
8. Enable Upcoming Features
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).
9. Change to Swift 6 Language Mode
Xcode Build Settings:
Swift Language Version = Swift 6Swift Package Manager:
// swift-tools-version: 6.0If 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
Migration Tooling for Upcoming Features
Swift 6.2+ includes semi-automatic migration for upcoming features.
Xcode Migration
- Go to Build Settings → Find the upcoming feature (e.g., "Require Existential any")
- Set to Migrate (temporary setting)
- Build the project
- Warnings appear with Apply buttons
- Click Apply for each warning
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 fixPackage Migration
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 ExistentialAnyOutput:
> Applied 24 fix-its in 11 files (0.016s)
> Updating manifestThe tool automatically:
- Applies all fix-its
- Updates
Package.swiftto enable the feature
Available migrations (as of Swift 6.2):
ExistentialAny(SE-335)InferIsolatedConformances(SE-470)- More features will add migration support over time
Course Deep Dive: This topic is covered in detail in Lesson 12.6: Migration tooling for upcoming Swift features
Additional resource: Migration Tooling Video
Rewriting Closures to Async/Await
Using Xcode Refactoring
Three refactoring options available:
- Add Async Wrapper: Wraps existing closure-based method (recommended first step)
- Add Async Alternative: Rewrites method as async, keeps original
- Convert Function to Async: Replaces method entirely
⚠️ Known Issue: Refactoring can be unstable in Xcode. If you get "Connection interrupted" errors:
- Clean build folder
- Clear derived data
- Restart Xcode
- Simplify complex methods (shorthand if statements can cause failures)
Manual Rewriting Example
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:
- Less code to maintain
- Easier to reason about
- No nested closures
- Automatic error propagation
Course Deep Dive: This topic is covered in detail in Lesson 12.7: Techniques for rewriting closures to async/await syntax
Using @preconcurrency
Suppresses Sendable warnings from modules you don't control.
When to Use
// ⚠️ Third-party library doesn't support Swift Concurrency yet
@preconcurrency import SomeThirdPartyLibrary
actor DataProcessor {
func process(_ data: LibraryType) { // No Sendable warning
// ...
}
}Risks
- No compile-time safety: You're responsible for ensuring thread safety
- Hides real issues: Library might not be thread-safe at all
- Technical debt: Easy to forget to revisit later
Best Practices
- Don't use by default: Only add when compiler suggests it
- Check for updates first: Library might have a newer version with concurrency support
- Document why: Add a comment explaining why it's needed
- Revisit regularly: Set reminders to check if library has been updated
// TODO: Remove @preconcurrency when SomeLibrary adds Sendable support
// Last checked: 2026-01-07 (version 2.3.0)
@preconcurrency import SomeLibraryThe compiler will warn if @preconcurrency is unused:
'@preconcurrency' attribute on module 'SomeModule' is unusedCourse Deep Dive: This topic is covered in detail in Lesson 12.8: How and when to use @preconcurrency
Migrating from Combine/RxSwift
Observation Alternative
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:
- Use
@Observablemacro for SwiftUI - Use
AsyncStreamfor custom observation - Consider AsyncExtensions package
Debouncing Example
Combine:
$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)
}
}
}Mindset Shift
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.
⚠️ Critical: Actor Isolation with Combine
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:
- Migrate to Swift Concurrency (recommended):
Task { [weak self] in
for await _ in NotificationCenter.default.notifications(named: .someNotification) {
await self?.handleNotification() // ✅ Compile-time safe
}
}- Use Task wrapper (temporary):
.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 to Use AsyncAlgorithms
When migrating from Combine or RxSwift, you have multiple options for handling asynchronous patterns:
Use AsyncAlgorithms for:
- Time-based operations: debounce, throttle, timers
- Combining multiple async sequences: merge, combineLatest, zip
- Multi-consumer scenarios: AsyncChannel for backpressure
- Complex operator chains: FRP-like patterns in Swift Concurrency
- Specific operators: removeDuplicates, chunks, adjacentPairs, compacted
Use Standard Library for:
- Bridging callbacks: AsyncStream is sufficient
- Simple iteration: for await in sequence
- Single-value operations: async/await
- Basic transformations: map, filter, contains
Use SwiftUI for:
- UI observation: @Observable macro
- State management: @State, @Published properties
- User interactions: onChange, onReceive modifiers
See: async-algorithms.md for detailed AsyncAlgorithms usage examples.
Real-World Migration Examples
Example: ArticleSearcher with AsyncAlgorithms
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:
- Automatic cancellation when new values arrive
- Backpressure handling (producer respects consumer pace)
- Cleaner code than manual Task.sleep management
- No need to track and cancel tasks manually
Example: Notification Stream Migration
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:
- Use
notifications(named:)for standard system notifications - Use
AsyncChannelfor custom multi-consumer notification scenarios - Use
@Observable+ SwiftUI for UI state updates
Example: Multi-Source Data Loading
Before: 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:
- Combine
merge()combines publishers; AsyncAlgorithmsmerge()combines sequences - For parallel execution, use
TaskGroupinstead offlatMap - State updates can use
@MainActorinstead of.receive(on:)
Anti-Patterns to Avoid
❌ Don't Use Task.sleep for Debouncing
// ❌ 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.
❌ Don't Manually Combine Values
// ❌ 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:
- More state management
- Boilerplate code for each field
- Harder to add new fields
- No stream composition benefits
Solution: Use combineLatest() for cleaner, composable validation.
❌ Don't Share Streams Without AsyncChannel
// ❌ 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.
Concurrency-Safe Notifications (iOS 26+)
Swift 6.2 introduces typed, thread-safe notifications.
MainActorMessage
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.
AsyncMessage
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:
- Strongly typed (no
Anycasting) - Compile-time thread safety
- Clear isolation guarantees
Course Deep Dive: This topic is covered in detail in Lesson 12.10: Migrating to concurrency-safe notifications
Common Challenges
"It's Too Much Work"
Break it down:
- Migrate one package at a time
- Use 30-minute daily sessions
- Create checkpoints with small PRs
- Celebrate incremental progress
"My Team Isn't Ready"
Start small:
- Enable Swift 6 for new files only
- Make new types
Sendableby default - Share learnings in team meetings
- Pair program on tricky migrations
"Dependencies Aren't Ready"
Options:
- Update to latest versions first
- Use
@preconcurrencytemporarily - Contribute fixes to open-source dependencies
- Wrap third-party APIs in your own concurrency-safe layer
"I Keep Going in Circles"
This is the "concurrency rabbit hole":
- Take breaks and revisit later
- Temporarily disable strict checking to make progress elsewhere
- Focus on one module at a time
- Don't try to fix everything at once
Course Deep Dive: This topic is covered in detail in Lesson 12.11: Frequently Asked Questions (FAQ) around Swift 6 Migrations
Common Mistakes Agents Make
- Blanket
@MainActor: Do not slap@MainActoron everything to silence errors. Ask whether the code truly needs main-actor isolation. - Mixing migration with unrelated refactors: Focus solely on concurrency changes. Architectural improvements belong in separate PRs.
- Using
@unchecked Sendableas a first response: Prefer immutable value types or actors. Reserve escape hatches for documented, temporary exceptions. - Giving pre-Swift 6.2 execution advice without checking the active feature set:
nonisolated asyncbehavior depends on whetherNonisolatedNonsendingByDefaultis enabled. - Using Approachable Concurrency without migrating feature-by-feature first: Enable individual upcoming features before the full bundle to understand each change's impact.
Summary
Migration to Swift 6 is a journey, not a sprint:
- Start small: Find isolated code with minimal dependencies
- Be incremental: Use the three strict concurrency levels (Minimal → Targeted → Complete)
- Use tooling: Leverage Xcode refactoring and
swift package migrate - Create checkpoints: Small, focused PRs that can be merged quickly
- Stay positive: The concurrency rabbit hole is real, but manageable with the right habits
- Think differently: Let go of the threading mindset; trust the compiler
The result is compile-time thread safety, more maintainable code, and a future-proof codebase.
Additional resources:
- Approachable Concurrency Video
- Migration Tooling Video
- Swift Concurrency Course for in-depth migration strategies