- Published on
- 8 min read Advanced
> Data Races Swift 6 Still Can't Catch
Swift 6's headline feature was compile-time data-race safety. Flip the language mode on, fix the warnings, and the compiler will stop you from sharing mutable state across isolation boundaries. That is a genuinely big deal. It catches a huge class of bugs that used to require production crashes and a debugger to find.
It is also not the whole story. "Data-race safe" means the compiler has eliminated races it can see. There are several places it cannot see, and in those places you are back to the old rules: think carefully, test under load, and do not trust a green build as evidence that nothing is racing.
Here is the set of escape hatches and blind spots worth keeping an eye on.
Objective-C Classes Crossing the Bridge
Any NSObject-derived type imported from Objective-C is opaque to Swift's Sendable analysis. The compiler sees the public interface and whatever nullability and mutability annotations the framework author wrote, but it cannot reason about the internal state. A perfectly innocent-looking class property can be backed by an NSMutableArray, an NSMutableDictionary, or a CoreFoundation handle with its own internal buffers.
// Imagine this comes from a legacy Objective-C framework
@objc public class LegacyCache: NSObject, @unchecked Sendable {
private let entries = NSMutableArray()
@objc public func add(_ entry: String) {
entries.add(entry)
}
@objc public func snapshot() -> [String] {
entries.compactMap { $0 as? String }
}
}
The compiler accepts this without complaint. @unchecked Sendable is the author promising "I know what I'm doing." The runtime, meanwhile, will happily let two tasks call add at the same time and corrupt the internal buffer of the NSMutableArray. You will see this as a crash inside Foundation with a stack trace that looks like something else went wrong, because the Foundation code was never written to expect concurrent mutation.
The same caution applies any time you see @preconcurrency import on a framework. That attribute tells the compiler to downgrade Sendable diagnostics for types in that module to warnings, or suppress them entirely, so you can adopt strict concurrency incrementally. The warnings do not come back on their own once the framework is fixed. They come back when you remove the attribute, which is easy to forget.
@unchecked Sendable on Your Own Types
The Swift 6 version of the same hole is @unchecked Sendable on a class you wrote yourself. The syntax is documented clearly in SE-0302, and the rule is simple: the compiler skips the checks and trusts you to provide the synchronization yourself.
final class MetricsRecorder: @unchecked Sendable {
private var samples: [Double] = []
func record(_ value: Double) {
samples.append(value)
}
func drain() -> [Double] {
let copy = samples
samples.removeAll()
return copy
}
}
This compiles. It also races. The fix is either a lock, an actor, or a serial dispatch queue guarding samples. The compiler does not know any of that is missing. It sees @unchecked Sendable and stops asking questions.
@unchecked is occasionally the right answer when you are wrapping a type that already provides thread safety (for example, a C library with its own mutex, or a property backed by an atomic from Swift Atomics). It is the wrong answer when you add it to make a warning go away.
nonisolated(unsafe)
SE-0412 tightened the rules around global and static variables, and gave us nonisolated(unsafe) as the explicit opt-out for cases the compiler cannot model.
nonisolated(unsafe) var sharedCounter = 0
This is fine when the variable is actually safe to touch from anywhere, for example because it is written once at startup and only read afterward, or because every access goes through an external mutex you added yourself. It is not fine when you use it to silence a diagnostic about a legitimately shared mutable value. The attribute name is trying to warn you. Treat it as a load-bearing comment aimed at the next person who reads the file.
Unsafe Pointer APIs
UnsafeMutablePointer, UnsafeMutableBufferPointer, and the rest of the Unsafe* family sit entirely outside Swift's concurrency analysis. Once you have a pointer, the compiler has no idea who else has the same pointer, how long it lives, or whether anyone is reading through it while you write.
func fill(_ buffer: UnsafeMutableBufferPointer<UInt8>) async {
await withTaskGroup(of: Void.self) { group in
for i in 0..<buffer.count {
group.addTask {
buffer[i] = UInt8(i % 256) // compiles, races
}
}
}
}
Each task is writing to a different index, so in theory this is race free, but the compiler is not verifying that. It is letting the pointer through because pointers are explicitly an unsafe escape hatch. Change the indexing to buffer[0] = ... in every task and the code still compiles with no diagnostic at all, and now you have a real race.
The same goes for withUnsafeMutableBytes, Unmanaged, and anything else with "Unsafe" in the name. The word is not decorative.
Global C Variables and C Interop
C globals are completely invisible to Sendable checking. If you import a C header that declares extern int g_counter;, Swift will import g_counter as an Int32 you can read and write from anywhere, with no isolation, no @MainActor, and no warnings. Everything that is true of @unchecked Sendable is also true here, only more so, because there is not even a Swift declaration you can attach an annotation to.
The same applies to callbacks you register with C APIs. If a C library calls your function pointer from whichever thread it happens to be on, that is the thread your Swift code runs on, and any Swift state that callback touches is effectively crossing a concurrency boundary the compiler never saw.
A Realistic Example
Put two of these together and you get something that compiles cleanly in Swift 6 strict mode, passes code review if nobody looks too closely at the annotations, and breaks in production.
final class EventBuffer: @unchecked Sendable {
private let events = NSMutableArray()
func append(_ event: String) {
events.add(event)
}
func flush() -> [String] {
let snapshot = events.compactMap { $0 as? String }
events.removeAllObjects()
return snapshot
}
}
@MainActor
final class AnalyticsCoordinator {
let buffer = EventBuffer()
func track(_ name: String) {
Task.detached {
await Task.yield()
self.buffer.append(name) // racing with flush()
}
}
func flushPeriodically() async {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(5))
let pending = buffer.flush() // racing with append()
await upload(pending)
}
}
}
Every line of this compiles under -strict-concurrency=complete. The @unchecked Sendable on EventBuffer lets the coordinator hand it out to detached tasks, and the NSMutableArray inside is the actual shared mutable state. Under load, flushPeriodically and the detached task will eventually collide, usually manifesting as a Foundation crash that looks nothing like a concurrency bug.
The fix is not difficult. Wrap the NSMutableArray access in a lock, or better, rewrite EventBuffer as an actor and drop the @unchecked. The point is not that the bug is hard to solve, it is that the compiler never flagged it in the first place.
How to Work Around the Blind Spots
A short checklist for the unsafe boundaries you cannot avoid:
- Treat every
@unchecked Sendable,nonisolated(unsafe), and@preconcurrency importas a code review flag. Somebody is making a claim the compiler cannot verify. The claim needs to be true. - Prefer actors and
Sendablevalue types at the boundary with anything unsafe. Keep the dangerous surface area as small as possible, and let Swift-native code upstream of it benefit from the compiler's checks. - Exercise the unsafe paths under Thread Sanitizer.
-sanitize=threadstill catches races the compiler cannot see, and it is cheap to run in CI if you already have a test suite. - Write down why an unsafe annotation is correct. "This is safe because the underlying C library uses a pthread mutex" is a useful comment. "Compiler made me add this" is not.
The Takeaway
Swift 6's data-race safety is one of the most valuable static checks the language has ever shipped. It closes off an enormous category of bugs that used to require careful discipline to avoid. But it is a static check, and static checks stop at the edges of what the compiler can see. Objective-C types, @unchecked Sendable, unsafe pointers, C globals, and explicit opt-outs all sit on the other side of that edge.
"Data-race safe" is shorthand for "the compiler has eliminated a large class of races." It is not a claim that no races are possible. Testing, code review, and Thread Sanitizer still earn their keep at the unsafe boundaries. A clean Swift 6 build is a strong signal, not a proof.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Async defer in Swift 6.4
SE-0493 finally lets you write defer { await cleanup() } in async functions, without spawning a detached task or threading cleanup logic through every return path.
Task Cancellation Shields in Swift 6.4
Swift 6.4's withTaskCancellationShield lets cleanup code run to completion even after a task has been cancelled, without spawning extra unstructured tasks.
Using @concurrent in Swift 6.2
Swift 6.2 changes how async functions pick a thread by default. The new @concurrent attribute lets you explicitly opt into background execution when you actually need it.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.