Swift Concurrency introduced one of the most important safety improvements in the language: compile-time data race detection.
Before Swift Concurrency, it was possible to accidentally access mutable state from multiple threads at the same time. These bugs were notoriously difficult to reproduce because they depended on timing and thread scheduling. An application might work perfectly for months and then suddenly crash or corrupt data in production.
Swift's concurrency model takes a different approach:
Instead of trying to detect data races at runtime, Swift attempts to prevent them from being written in the first place.
At the center of this system is the Sendable protocol.
This article explores:
Sendable meansSendable conformance@Sendable closuresBefore understanding Sendable, we must understand the problem it solves.
A data race occurs when:
Consider:
class Counter {
var value = 0
}
let counter = Counter()
DispatchQueue.global().async {
counter.value += 1
}
DispatchQueue.global().async {
counter.value += 1
}Both threads are modifying the same memory location.
Possible outcomes:
Expected: 2
Actual:
1
2
Undefined behaviorThe result depends entirely on timing.
This is called a data race.
Data races can cause:
For example:
class BankAccount {
var balance = 1000
func withdraw(_ amount: Int) {
balance -= amount
}
}Two threads simultaneously execute:
account.withdraw(500)You might expect:
Balance = 0But race conditions can produce:
Balance = 500because both threads read the original value before either write occurs.
Historically, developers solved this using:
Swift Concurrency introduces a stronger guarantee:
Certain categories of race conditions can be detected during compilation.
The compiler examines values crossing concurrency boundaries and determines whether those values are safe to share.
This is where Sendable comes in.
Sendable?Sendable is a marker protocol.
protocol Sendable { }At first glance it looks empty.
That is intentional.
The protocol does not add functionality.
Instead, it communicates a guarantee:
Values of this type can be safely transferred between concurrent execution contexts.
Examples include:
If a type conforms to Sendable, Swift assumes it can safely move between those contexts.
Consider:
Task {
await processUser(user)
}The value:
useris crossing a concurrency boundary.
The compiler asks:
Is this value safe to share with another concurrent task?
If the answer is yes:
SendableIf not:
Compiler Warning/ErrorMost value types naturally work well with concurrency.
Example:
struct User: Sendable {
let id: UUID
let name: String
}Because structs are copied on assignment:
let user = User(id: UUID(), name: "John")
let user1 = user
let user2 = usereach task gets its own value.
No shared mutable memory exists.
This makes value types ideal candidates for Sendable.
Swift can automatically synthesize conformance when all stored properties are already Sendable.
Example:
struct Product: Sendable {
let id: Int
let name: String
let price: Double
}Every property is Sendable.
The compiler verifies this automatically.
No additional work is required.
Consider:
class UserManager {
var users: [String] = []
}
struct AppState: Sendable {
let manager: UserManager
}Compiler error:
Stored property 'manager' of 'Sendable'-conforming struct 'AppState' has non-Sendable type 'UserManager'Why?
Because classes are reference types.
Multiple tasks could share the same instance:
taskA ---> UserManager
taskB ---> UserManagerBoth tasks could mutate:
userssimultaneously.
Swift therefore rejects it.
Consider:
class Counter {
var value = 0
}Now:
let counter = Counter()
Task {
counter.value += 1
}
Task {
counter.value += 1
}Both tasks access the same object.
Because classes are reference types, sharing is possible.
Swift therefore assumes ordinary classes are not Sendable.
Sometimes a class never changes after creation.
Example:
final class Configuration: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}All properties are immutable.
No race condition can occur.
The compiler accepts this conformance.
Now consider:
final class Configuration: Sendable {
var apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}Compiler error:
Stored property 'apiKey' of 'Sendable'-conforming class 'Configuration' is mutableThe compiler cannot guarantee thread safety.
Therefore conformance is rejected.
Actors are designed specifically for shared mutable state.
Example:
actor BankAccount {
private var balance = 1000
func withdraw(_ amount: Int) {
balance -= amount
}
func currentBalance() -> Int {
balance
}
}Usage:
let account = BankAccount()
await account.withdraw(500)
await account.withdraw(500)The actor guarantees:
Only one task accesses actor state at a time.This eliminates many race conditions.
Actor references automatically conform to Sendable.
actor Logger {
func log(_ message: String) { }
}This is allowed:
let logger = Logger()
Task {
await logger.log("Hello")
}because actor isolation provides safety.
Enums can also conform automatically.
enum NetworkState: Sendable {
case idle
case loading
case success(String)
case failure(ErrorMessage)
}As long as associated values are Sendable, the enum is Sendable.
Generics require additional constraints.
Example:
struct Container<T>: Sendable {
let value: T
}Compiler error:
Stored property 'value' of 'Sendable'-conforming generic struct 'Container' has non-Sendable type 'T'The compiler knows nothing about T.
Fix:
struct Container<T: Sendable>: Sendable {
let value: T
}Now Swift guarantees:
Every T used here must also be Sendable.@Sendable?So far we've discussed types.
Closures can also cross concurrency boundaries.
Example:
Task.detached {
print("Hello")
}The closure is executed concurrently.
Swift therefore treats it as:
@Sendable@Sendable ExistsConsider:
var count = 0
let closure = {
count += 1
}The closure captures:
countNow imagine multiple tasks executing it simultaneously.
A race condition becomes possible.
@Sendable prevents unsafe captures.
class Counter {
var value = 0
}
let counter = Counter()
Task.detached {
counter.value += 1
}You may see warning:
Main actor-isolated property 'value' can not be mutated from a nonisolated contextSwift is warning that the detached task could access shared mutable state.
One solution is to use an actor.
actor Counter {
var value = 0
func increment() {
value += 1
}
}Now:
let counter = Counter()
Task.detached {
await counter.increment()
}The compiler is satisfied because actor isolation guarantees safety.
Detached tasks are one of the most common places where Sendable checks appear.
Example:
Task.detached {
await performWork()
}A detached task has no relationship to the current actor.
Everything captured inside must therefore be safe to transfer.
Swift performs strict Sendable checking here.
Swift 6 dramatically strengthens concurrency checking.
Code that previously produced warnings may now produce errors.
Example:
class SessionManager {
var token = ""
}let manager = SessionManager()
Task.detached {
print(manager.token)
}Swift 5.0:
WarningSwift 6.0:
ErrorThe goal is stronger compile-time race prevention.
@unchecked SendableSometimes you know a type is thread-safe but the compiler cannot verify it.
Example:
class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: String] = [:]
func set(_ value: String, for key: String) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
}@unchecked Sendable tells Swift:
Trust me. I guarantee thread safety.
The compiler stops validating the internals.
@unchecked Sendable?Only when:
Avoid using it merely to silence compiler errors.
Incorrect usage can reintroduce data races.
A common pattern:
struct UserResponse: Codable, Sendable {
let id: Int
let name: String
let email: String
}Now the model can safely travel through:
without concurrency warnings.
actor UserService {
func fetchUser() async throws -> UserResponse {
// Network call
}
}Because:
UserResponseis Sendable, it can safely cross the actor boundary.
let user = try await service.fetchUser()No race conditions occur.
class Logger { }
struct AppState: Sendable {
let logger: Logger
}Error:
Stored property 'logger' of 'Sendable'-conforming struct 'AppState' has non-Sendable type 'Logger'struct Box<T>: Sendable {
let value: T
}Error:
Stored property 'value' of 'Sendable'-conforming generic struct 'Box' has non-Sendable type 'T'Fix:
struct Box<T: Sendable>: Sendable {
let value: T
}Task.detached {
self.updateUI()
}Error:
Main actor-isolated instance method 'updateUI()' cannot be called from outside of the actorFix:
MainActorThese concepts solve different problems.
| Feature | Purpose |
|---|---|
Sendable |
Safe transfer between concurrency domains |
actor |
Safe access to shared mutable state |
@Sendable |
Safe closure capture |
@unchecked Sendable |
Manual thread-safety guarantee |
Think of them together:
Sendable
↓
Can this value cross boundaries safely?
Actor
↓
Can shared mutable state be protected safely?The real innovation is not the Sendable protocol itself.
The innovation is that Swift uses it to build a static safety system.
Before Swift Concurrency:
Write code
Run app
Hope races don't happen
Debug production crashesWith Swift Concurrency:
Write code
Compiler detects unsafe sharing
Fix issue before shippingMany concurrency bugs never reach production.
That is a significant shift in software reliability.
struct User: Sendableis generally better than:
class Userfor data models.
actor Cache { }instead of manually managing locks whenever possible.
Sendablestruct Invoice: Codable, SendableThis is especially useful in modern async codebases.
@unchecked Sendable Unless NecessaryTreat it as an escape hatch.
Use it only when thread safety has been carefully designed and verified.
Do not simply suppress them.
Most are exposing genuine race-condition risks.
Sendable is one of the foundational pieces of Swift's modern concurrency system. While the protocol itself appears deceptively simple, it enables something extremely powerful: compile-time verification that data can safely move between concurrent execution contexts.
Combined with actors, task isolation, and @Sendable closures, Swift can detect entire classes of concurrency bugs before the application ever runs.
The practical mental model is:
Sendable answers: Can this value safely cross a concurrency boundary?@Sendable answers: Can this closure safely execute concurrently?actor answer: How do we safely protect shared mutable state?Together, these features move Swift away from the traditional "find races at runtime" model and toward a future where many race conditions are impossible to write at all. For developers building modern Swift applications, understanding Sendable is not optional, it is a core skill for writing safe, scalable, and concurrency-friendly code.
If you have suggestions, feel free to connect with me on X and send me a DM. If this article helped you, Buy me a coffee.