Static Property Is Not Concurrency-Safe in Swift 6: Fix It Without Hiding Races

Swift · Intermediate · 7 min read · published

This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.

What this solves: Switching to the Swift 6 language mode turns every mutable static or global var into a compile error. Here is which of the four fixes is right for each case.

What Changed

When you turn on the Swift 6 language mode, the compiler flags mutable global and static state. The error reads Static property 'shared' is not concurrency-safe because it is nonisolated global shared mutable state, and it appears on almost every static var in the codebase. In Swift 5 mode, the same code compiled silently, or produced at most a warning under -strict-concurrency=complete.

The rule comes from SE-0412 (strict concurrency for global variables). It shipped as an error with the Swift 6 compiler (Xcode 16). It only applies to modules that opt into language mode 6, which you set with swiftLanguageModes: [.v6] in a swift-tools-version: 6.0 manifest or SWIFT_VERSION = 6 in Xcode.

A second variant appears once you "fix" the first one by changing var to let:

Static property 'shared' is not concurrency-safe because non-'Sendable' type 'APIClient' may have shared mutable state

The same release added nonisolated(unsafe) as an explicit escape hatch. The Synchronization module added Mutex and Atomic (OS availability: iOS 18 / macOS 15).

The Old Way vs The New Way

// Swift 5 mode: compiles, races silently
final class APIClient {
    static var shared = APIClient()      // anyone can reassign it
    var token: String?                    // anyone can mutate it from any thread
    static var formatter = DateFormatter()
    static var requestCount = 0
}
// Swift 6 mode: every global says how it is protected
final class APIClient: Sendable {
    static let shared = APIClient()               // immutable + Sendable
    private let token = Mutex<String?>(nil)       // locked mutable state
    func setToken(_ t: String?) { token.withLock { $0 = t } }
}

@MainActor enum UIFormatters {                    // isolated to one actor
    static let date: DateFormatter = { let f = DateFormatter(); f.dateStyle = .medium; return f }()
}

actor Metrics {                                   // actor-owned counter
    static let shared = Metrics()
    private(set) var requestCount = 0
    func increment() { requestCount += 1 }
}

Why It Was Added

A global var is the easiest data race to write in Swift.

SE-0412 makes the compiler prove one of these is true for each global:

This eliminates the bug class at compile time instead of in crash logs.

How It Works Underneath

For each global or static declaration, the compiler runs a short decision procedure:

  1. Isolated to a global actor? Something like @MainActor. Every access is then checked to occur on that actor, or via await.
  2. A let whose type conforms to Sendable? Then sharing it across isolation domains is safe by construction.
  3. Marked nonisolated(unsafe)? Checking is skipped and the burden moves to you.
  4. None of the above? The declaration is an error.

Sendability is the second gate. A final class is Sendable only if:

flowchart TD
    A[global or static declaration] --> B{global actor isolated? e.g. @MainActor}
    B -- yes --> OK1[OK: accesses must run on that actor or await]
    B -- no --> C{declared with let?}
    C -- no --> D{nonisolated unsafe?}
    C -- yes --> E{type conforms to Sendable?}
    E -- yes --> OK2[OK: safe to share across tasks]
    E -- no --> D
    D -- yes --> W[Compiles: no checking, races are your problem]
    D -- no --> ERR[Error: not concurrency-safe]

There is one subtlety. Lazy initialization of the global is still thread-safe, because it uses swift_once. The check is about what happens after initialization.

Should You Adopt It Yet

Yes, for new modules and libraries. Swift 6 mode is stable and the checks catch real bugs. Library authors gain the most, because downstream Swift 6 clients need your types to be Sendable.

Large existing apps should stage it, not flip it. Expect three costs:

Teams with mostly UI code may prefer the Swift 6.2 approach. It lets a module default to main-actor isolation (defaultIsolation(MainActor.self) in SwiftPM), which removes most of these errors for UI code. Treat it as a project-wide decision, not a quick fix.

Never adopt Swift 6 by bulk-applying nonisolated(unsafe). That produces the Swift 5 behavior with a false sense of safety.

Migration Notes

  1. Get warnings first. Stay in Swift 5 mode and add .enableUpcomingFeature("GlobalConcurrency") or -strict-concurrency=complete to one target. You'll see warnings with no build breakage.
  2. Find the targets. Grep for static var, top-level var, and static let shared. Triage each into one of four buckets:
    • Never mutated after init: make it a let, and make the type Sendable (make lets private, mark the class final).
    • UI-only: use @MainActor.
    • Shared mutable state with async callers: use an actor.
    • Shared mutable state that needs synchronous access: use a Mutex- or lock-protected value.
  3. Audit the escape hatch. nonisolated(unsafe) is acceptable only where a lock or serial queue genuinely guards the value, or for values set once in main before any concurrency starts. Leave a comment naming the guard.
  4. Fix the second error separately. "non-'Sendable' type may have shared mutable state" means the problem is the type, not the declaration. Fix it at the type, rather than annotating every static that holds it.
  5. Flip module by module. Switch leaf modules to .v6 first and the app target last. Run Thread Sanitizer on CI for the transition period.

Key takeaway: For each flagged global, choose: immutable Sendable let, global-actor isolation, an actor, or a lock-protected Sendable box. Reserve nonisolated(unsafe) for state you can prove is protected.

Real-world challenge

Your team migrated an iOS app to Swift 6 mode in an afternoon. They fixed about 40 errors reading `Static property 'cache' is not concurrency-safe because it is nonisolated global shared mutable state` by prefixing each one with `nonisolated(unsafe)`. The build is green. A week after release, crash reports show EXC_BAD_ACCESS inside `Dictionary._Variant` called from `ImageLoader.cache`, always on background threads during fast scrolling. How do you diagnose and fix it?

Diagnosis. nonisolated(unsafe) told the compiler, "trust me, this is synchronized." It wasn't. ImageLoader.cache is a plain [URL: UIImage] dictionary written from several concurrent download tasks. Swift dictionaries are not thread-safe, so concurrent writes corrupt the storage. The Swift 6 error was pointing at a real bug, and the annotation hid it.

To confirm, run the scrolling flow with Thread Sanitizer enabled. It will report the race on the dictionary.

Fix. Give the state real isolation. If you can await, use an actor:

actor ImageCache {
    static let shared = ImageCache()   // actor is Sendable, let is fine
    private var storage: [URL: UIImage] = [:]
    func image(for url: URL) -> UIImage? { storage[url] }
    func insert(_ image: UIImage, for url: URL) { storage[url] = image }
}

If callers must stay synchronous and you target iOS 18 or later, use Mutex from the Synchronization module. On older targets, use OSAllocatedUnfairLock (iOS 16+):

import Synchronization
enum ImageLoader {
    static let cache = Mutex<[URL: UIImage]>([:])
}
ImageLoader.cache.withLock { $0[url] = image }

Then audit the other 39 annotations. grep -rn "nonisolated(unsafe)", and for each hit, write down which lock or queue actually protects the value. If you can't name one, replace the annotation.