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.
- Lazy singletons are initialized once and safely. Their mutable contents are not protected at all.
- Before Swift 6, async/await made concurrent access easier to write, but nothing checked it. A
static var cache: [String: Data]touched from twoTasks crashed intermittently insideDictionaryinternals. Thread Sanitizer catches this only if the race actually fires during a test run.
SE-0412 makes the compiler prove one of these is true for each global:
- nobody can mutate it, or
- all access is serialized by an actor, or
- you explicitly take responsibility.
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:
- Isolated to a global actor? Something like
@MainActor. Every access is then checked to occur on that actor, or viaawait. - A
letwhose type conforms toSendable? Then sharing it across isolation domains is safe by construction. - Marked
nonisolated(unsafe)? Checking is skipped and the burden moves to you. - None of the above? The declaration is an error.
Sendability is the second gate. A final class is Sendable only if:
- all its stored properties are immutable
lets ofSendabletypes, or - you declare
@unchecked Sendablebecause you guard the state internally, which is how aMutexfield makes this legitimate.
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:
- Cascades. One
@MainActorannotation can ripple through every caller. - Non-Sendable dependencies. Older SDK or third-party types need
@preconcurrency importuntil those libraries adoptSendable. - Availability limits.
Mutexrequires iOS 18 / macOS 15. Older deployment targets fall back toOSAllocatedUnfairLock(iOS 16+) or a class wrappingNSLockmarked@unchecked Sendable.
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
- Get warnings first. Stay in Swift 5 mode and add
.enableUpcomingFeature("GlobalConcurrency")or-strict-concurrency=completeto one target. You'll see warnings with no build breakage. - Find the targets. Grep for
static var, top-levelvar, andstatic let shared. Triage each into one of four buckets:- Never mutated after init: make it a
let, and make the type Sendable (makelets private, mark the classfinal). - 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.
- Never mutated after init: make it a
- Audit the escape hatch.
nonisolated(unsafe)is acceptable only where a lock or serial queue genuinely guards the value, or for values set once inmainbefore any concurrency starts. Leave a comment naming the guard. - 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.
- Flip module by module. Switch leaf modules to
.v6first 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.