All articles
iOS8 min read

I Counted Every Compiler Error in a Swift 6 Strict Concurrency Migration

I migrated a Swift 5 module to strict concurrency and counted the compiler errors at every stage. Eight errors became two from one build setting, and the real cost was somewhere I did not expect.

K
Karan Pal
Author
I Counted Every Compiler Error in a Swift 6 Strict Concurrency Migration

All numbers from real builds - Swift 6.3.3, Xcode 26.6, macOS 26.5.2.

Every guide to Swift 6 strict concurrency says some version of the same thing. Turn on the new 6.2 settings, and your error count will drop a lot, and what's left will be a small number of real problems.

I've read that in maybe a dozen places now. None of them say what the number is.

So I went and got it. I wrote a module that looks like the iOS code most of us were writing in 2020: a singleton cache with an NSLock, completion-handler networking, an ObservableObject with a DispatchQueue.main.async buried in it, a download manager driven by Timer and a delegate, a couple of global vars. Then I walked it through every stage of the migration and counted what the compiler said.

Stage                                        Warnings   Errors
--------------------------------------------------------------
Swift 5 language mode, default checking             0        0
Swift 5 + -strict-concurrency=complete             21        0
Swift 6 language mode                               8        8
Swift 6 + defaultIsolation(MainActor.self)          9        2

Eight errors down to two, from one build setting. That's the number the guides don't print.

The table isn't the interesting part though. What I didn't expect was what happened when I tried to fix the last two.

Where it starts

Zero warnings, zero errors. That's the thing about this migration. None of the code is wrong yet.

public final class ImageCache {
    public static let shared = ImageCache()

    private var storage: [URL: Data] = [:]
    private let lock = NSLock()

    public func data(for url: URL) -> Data? {
        lock.lock()
        defer { lock.unlock() }
        return storage[url]
    }
}

public var currentUserID: String?
public var isLoggingEnabled = true

You've written this class. I've written this class more than once. It's thread-safe, it's been in production for years, and the compiler has never said a word about it.

Turning checking on, without turning it on

The usual advice is to stay in Swift 5 mode and raise the checking level first, so you get warnings instead of a broken build.

.swiftSettings: [
    .swiftLanguageMode(.v5),
    .unsafeFlags(["-strict-concurrency=complete"])
]

21 warnings. Nothing stops working. You can ignore every one of them for as long as you like, which is roughly why so many codebases have been parked at this stage since 2024.

Swift 6 mode, and one error repeated eight times

Switch to .swiftLanguageMode(.v6) and those warnings turn into errors. Eight of them.

They're all the same error.

DownloadManager.swift:10:23: error: static property 'shared' is not concurrency-safe because
  non-'Sendable' type 'DownloadManager' may have shared mutable state [#MutableGlobalVariable]
FeedService.swift:5:23:     error: static property 'shared' ... [#MutableGlobalVariable]
ImageCache.swift:6:23:      error: static property 'shared' ... [#MutableGlobalVariable]
ImageCache.swift:33:12:     error: var 'currentUserID' is not concurrency-safe because it is
  nonisolated global shared mutable state [#MutableGlobalVariable]
ImageCache.swift:34:12:     error: var 'isLoggingEnabled' ... [#MutableGlobalVariable]
PlaybackReporter.swift:5:23:  error: static property 'isEnabled' ... [#MutableGlobalVariable]
PlaybackReporter.swift:6:23:  error: static property 'flushInterval' ... [#MutableGlobalVariable]
PlaybackReporter.swift:8:24:  error: static property 'pending' ... [#MutableGlobalVariable]

Every one is #MutableGlobalVariable. Singletons and global vars. I'd braced for a wall of sendability complaints and actor-hop errors and got none of either, which honestly made me suspicious that I'd misconfigured something.

The setting that does most of the work

Swift 6.2 turned the model around. Instead of your code being nonisolated by default while you annotate your way onto the main actor, you declare the main actor as the default and opt out in the places you mean it.

.swiftSettings: [
    .swiftLanguageMode(.v6),
    .defaultIsolation(MainActor.self)
]

In Xcode that's SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, plus SWIFT_APPROACHABLE_CONCURRENCY = YES.

Eight errors became two.

All eight #MutableGlobalVariable errors went away, and not because anything was suppressed. That state was being touched from the main thread in every path that mattered. The compiler just had no way to know, and I'd never told it. Setting the default told it.

What was left looked nothing like what went away:

FeedService.swift:20:65: error: sending 'completion' risks causing data races [#SendingRisksDataRace]
FeedService.swift:30:40: error: sending 'completion' risks causing data races [#SendingRisksDataRace]

That's a URLSession callback, which really does come back on a background thread, handing a closure into main-actor context. Two real boundaries in the whole module. The compiler found them and ignored everything that only looked dangerous.

So the honest version of this migration is: most of your errors aren't bugs, they're missing information. A couple of them are bugs.

The fix that isn't a fix

The obvious move on a "sending risks data races" error is to mark the closure @Sendable.

public func loadPodcast(id: String, completion: @escaping @Sendable (Podcast?, Error?) -> Void)

Two errors became three, in different files.

FeedViewModel.swift:19:18: error: main actor-isolated property 'isLoading'
  can not be mutated from a Sendable closure
FeedViewModel.swift:21:22: error: main actor-isolated property 'errorMessage' ...
FeedViewModel.swift:24:18: error: main actor-isolated property 'podcast' ...

I hadn't fixed anything. I'd taken the boundary out of the service and pushed it into every caller.

It makes sense once you stop and read it. @Sendable on a completion handler is a promise that the closure can be called from anywhere. The view model's closure updates UI state, so it can't be. Both things can't be true at once, and the compiler picked the right one to complain about.

This is the whack-a-mole people describe when they say strict concurrency is exhausting. It happens when you treat annotations as a way to make a message go away.

Deleting the completion handler instead

public func loadPodcast(id: String) async throws -> Podcast? {
    if let existing = inFlight[id] { return try await existing.value }

    let task = Task<Podcast?, Error> { [session] in
        guard let url = URL(string: "https://example.com/podcasts/\(id)") else { return nil }
        let (data, _) = try await session.data(from: url)
        return Self.parse(data: data, id: id)
    }
    inFlight[id] = task
    defer { inFlight[id] = nil }
    return try await task.value
}

And the caller:

public func load(id: String) {
    loadTask?.cancel()
    loadTask = Task {
        isLoading = true
        defer { isLoading = false }
        do { podcast = try await service.loadPodcast(id: id) }
        catch { errorMessage = error.localizedDescription }
    }
}

No DispatchQueue.main.async anywhere. Nothing to reason about regarding which thread the callback arrives on. The view model is main-actor isolated by default, so the @Published writes are provably on the main thread, and I didn't have to be the one checking.

One error left.

The wrong turn

That last error was in parse, a pure function with no reason to be sitting on the main actor. I'd marked it nonisolated. But it builds a Podcast, and with default isolation on, Podcast's initialiser is main-actor isolated too.

Fine, I thought. The models are pure data. Mark those nonisolated as well.

One error became eleven.

FeedService.swift:15:39: error: type 'Podcast' does not conform to the 'Sendable' protocol
FeedService.swift:18:13: error: type 'Podcast' does not conform to the 'Sendable' protocol
FeedService.swift:15:30: error: non-Sendable type 'Task<Podcast?, any Error>' cannot exit
  main actor-isolated context in call to nonisolated property 'value'
...

The cause was four lines I'd written at the very start and not thought about since.

public var artwork: Artwork?

public final class Artwork {
    public let url: URL
    public var cachedData: Data?
}

Artwork is a class with a mutable property. Episode holds one, so Episode isn't Sendable. Podcast holds [Episode], so Podcast isn't either. Once the models stopped being main-actor isolated, every boundary crossing in the module became illegal at once.

That's the part I'd underestimated, and I think it's the part most migration guides skip. The cost isn't in the concurrency code. It's in the model layer.

Default MainActor isolation hides this from you completely. If everything lives on one actor, nothing has to be Sendable, and nobody ever audits the models. The bill only arrives when you need real background work, and then it arrives all at once.

What fixed it wasn't a concurrency change. A mutable cache shouldn't have been living on a value type to begin with. It belongs in the cache.

public nonisolated struct Artwork: Sendable, Hashable {
    public let url: URL

    public func cachedData() -> Data? {
        ImageCache.shared.data(for: url)
    }
}

Strict concurrency didn't create that problem. It just stopped letting me ignore it.

The last bits

ImageCache genuinely does need to be reachable from anywhere, so it stays off the main actor. NSLock plus @unchecked Sendable becomes a Mutex, which actually proves the thing that @unchecked was asking the compiler to assume.

Mutex lives in the Synchronization module, so this file needs import Synchronization at the top.

public nonisolated final class ImageCache: Sendable {
    public static let shared = ImageCache()
    private let storage = Mutex<[URL: Data]>([:])

    public func data(for url: URL) -> Data? {
        storage.withLock { $0[url] }
    }
}

The DownloadManager had a private serial DispatchQueue guarding its array from concurrent mutation. I deleted it. Default isolation already guarantees that, and the queue hop was the specific thing making the mutation illegal.

Final state: zero errors, zero warnings, Swift 6 language mode, MainActor by default.

You're probably not behind

Somewhere in the middle of this I got curious about who has actually done this on a real app, so I read SWIFT_VERSION out of the project.pbxproj of every large open-source iOS app I could find.

App                   Swift files   Language mode                    Last commit
--------------------------------------------------------------------------------
WordPress-iOS               3,165   5.0, all 48 targets                 Aug 2026
Kickstarter ios-oss         2,050   4.2 and 5.0                         Jul 2026
Signal-iOS                  2,599   5.0                                 Jul 2026
Home Assistant              1,365   5.0                                 Aug 2026
Pocket Casts                1,793   5.0                                 Jul 2026
wikipedia-ios               1,321   mixed, 54 targets on 5.0            Jul 2026
firefox-ios                 3,136   127 on 6.0, 58 still on 5.0         Aug 2026
NetNewsWire                   680   6.2               <- done           Jul 2026
IceCubesApp                   424   6.0, strict + MainActor  <- done    Jun 2026

Almost two years after Swift 6 shipped, most big actively-maintained iOS apps are still in Swift 5 language mode. Kickstarter has targets on Swift 4.2 and shipped a commit last week.

The ones that have finished are the small ones. NetNewsWire is 680 Swift files, Ice Cubes is 424. Nothing in the 2,000-file range is done. Firefox is grinding through it target by target and is about two-thirds of the way.

What I'd tell you to do

Start with defaultIsolation(MainActor.self) and Swift 6 mode together, before you write a single Sendable conformance. Most of what you'd otherwise annotate by hand was main-thread code the whole time.

Read the diagnostic categories rather than the count. #MutableGlobalVariable is bookkeeping. #SendingRisksDataRace is a real bug. They don't deserve the same amount of your attention, and the raw number in Xcode's issue navigator tells you nothing about the ratio.

If a fix makes the error count go up, undo it. That's the compiler telling you the boundary belongs somewhere else, not that you need more annotations.

And budget for the model layer. That's where the actual work is, and you won't see it coming, because the setting that makes the migration easy is also the setting that keeps it hidden.

The whole thing took an afternoon on a small module. I was wrong about which part would be hard.

Caveats

This is a module I wrote to hold the classic patterns, not a shipping app. The shape of the results should carry over; the exact counts won't.

Counts are unique file:line:col diagnostics. Errors mask other errors, so the intermediate numbers are floors rather than totals.

Swift 6.3 on Xcode 26.6. The Swift 6.4 concurrency features (async defer, withTaskCancellationShield, ~Sendable) need Xcode 27 and aren't covered here.

The survey counts Xcode targets rather than modules, and one or two of those repos may be mirrors. Worth reproducing before quoting.

#Swift#iOS#Concurrency#Swift6#Xcode
● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading