Skip to content

What is the Sendable protocol in Swift? | Modern Swift Concurrency #11

When you pass a value from one task or actor to another, Swift needs to know that doing so cannot create a data race — Sendable is the compile-time proof that a type is safe to share across concurrency boundaries.

What You'll Learn

  • Why Sendable exists and what problem it solves in Swift's concurrency model
  • How value types get Sendable conformance automatically vs. how reference types must prove it
  • What @unchecked Sendable means, when it is legitimate, and why it is dangerous when misused
  • How Swift 6's strict concurrency mode turns Sendable violations from warnings into hard errors
  • How to cross actor isolation boundaries safely by designing types that are genuinely Sendable

Mental Model

Think of passing a value across a concurrency boundary like handing a document to a colleague in a different office building. If the document is a photocopy (a value type), there is no problem — each person has their own independent copy, and neither can affect the other's. But if you hand over the only original (a reference type), both people can now edit it simultaneously. Sendable is the guarantee written on the cover: "this document is a safe photocopy." Without that guarantee, Swift 6 refuses to let you hand the document over at all.

@unchecked Sendable is you writing "I pinky-promise this is safe" on an original document without actually making a copy. The compiler accepts your word, but you are now personally responsible for ensuring no two people write to it at the same time — usually via a lock or a serial dispatch queue.

Detailed Explanation

Swift's structured concurrency model introduces a fundamental question: can a value be safely shared between two concurrently-executing contexts? For a struct or enum whose properties are all themselves Sendable, Swift can answer "yes" automatically — because copying a value type produces an independent copy, there is never shared mutable state. For class types, the answer is usually "not without extra work," because classes are reference types and any two holders of the same instance can mutate it concurrently.

The Sendable protocol is the compiler-enforced answer. Swift automatically synthesizes Sendable conformance for value types when all stored properties are themselves Sendable. Actors are always Sendable because their own isolation mechanism prevents concurrent access. Final classes with only immutable (let) stored properties of Sendable types can also conform. In all other cases — particularly mutable classes — you must do additional work.

@unchecked Sendable is an escape hatch. It tells the compiler "trust me, I have arranged for thread safety myself." This is legitimate when you are wrapping a C library, bridging a legacy callback API, or managing a lock or serial queue by hand. In MyClassUserInfo, the DispatchQueue is the manual synchronization mechanism: all mutations to name go through queue.async, so no two callers can mutate name at the same time. Without that queue, @unchecked Sendable would be a lie that produces data races.

Swift 6 turns the stakes up significantly. With the StrictConcurrency build setting enabled, passing a non-Sendable type across an actor boundary becomes a compiler error, not just a warning. This means code that compiled cleanly in Swift 5 may fail to compile under Swift 6 if it was silently doing unsafe cross-actor data sharing. Adopting Sendable correctly now is the foundation for migrating to Swift 6 without rewrites.

The practical design rule: prefer struct for types that cross actor or task boundaries. Use final class with @unchecked Sendable only when reference semantics are truly necessary, and document the synchronization mechanism clearly. Never apply @unchecked Sendable to a mutable class without a lock or queue backing it.

Code Structure

The sample lives in SendableBootcamp.swift and demonstrates three distinct conformance patterns side by side: an actor (CurrentUserManager) that is inherently Sendable, a struct (MyUserInfo) that is automatically Sendable because all its properties are value types, and a final class (MyClassUserInfo) that uses @unchecked Sendable backed by a DispatchQueue for manual thread safety.

Complete Code

SendableBootcamp.swift

swift
import SwiftUI

// actor is always implicitly Sendable — its own isolation mechanism
// prevents concurrent access to its stored state.
actor CurrentUserManager {
    
    // `userInfo` crosses the actor boundary when passed from the caller's context.
    // The parameter type must be Sendable; the compiler enforces this at the call site.
    func updateDatabase(userInfo: MyClassUserInfo) {
        
    }
    
}

// struct with only Sendable stored properties gets automatic Sendable synthesis.
// `String` is Sendable, so `MyUserInfo` is Sendable with no extra work.
struct MyUserInfo: Sendable {
    var name: String
}

// `final` is required — a non-final class could be subclassed to add
// mutable state, which would break the Sendable guarantee.
// @unchecked Sendable: we promise thread safety, but the compiler cannot verify it.
final class MyClassUserInfo: @unchecked Sendable {
    private var name: String         // `private` prevents external mutation without going through the queue.
    let queue = DispatchQueue(label: "com.MyApp.MyClassUserInfo") // serial queue — the manual synchronization mechanism
    
    init(name: String) {
        self.name = name
    }
    
    // All mutations to `name` are dispatched asynchronously on the serial queue,
    // so no two callers can write to `name` at the same time.
    func updateName(name: String) {
        queue.async {
            self.name = name
        }
    }
}

// Not annotated @MainActor — shows the call site must handle the actor boundary explicitly.
class SendableBootcampViewModel: ObservableObject {
    
    let manager = CurrentUserManager()
    
    // `async` is required because `manager.updateDatabase` is an actor-isolated method.
    func updateCurrentUserInfo() async {
        
        // MyClassUserInfo is @unchecked Sendable, so it can cross into the actor's isolation domain.
        let info = MyClassUserInfo(name: "info")
        
        // `await` suspends here until CurrentUserManager's executor is free to accept the call.
        await manager.updateDatabase(userInfo: info)
    }
    
}

struct SendableBootcamp: View {
    
    @StateObject private var viewModel = SendableBootcampViewModel()
    
    var body: some View {
        Text("Hello, World!")
            .task {
                
            }
    }
}

struct SendableBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        SendableBootcamp()
    }
}

Code Walkthrough

  1. actor CurrentUserManager — Actors are unconditionally Sendable. The compiler automatically knows that any value passed into updateDatabase must itself be Sendable, because it is crossing from the caller's concurrency domain into the actor's isolated domain. If MyClassUserInfo were not Sendable, this code would not compile in Swift 6 strict mode.

  2. struct MyUserInfo: Sendable — The Sendable conformance here is explicit for clarity, but Swift would synthesize it automatically. Because name is a String (which is Sendable), the whole struct is safe to copy across any boundary. This is the preferred pattern: design data-transfer types as structs.

  3. final class MyClassUserInfo: @unchecked Sendable — This is the correct way to make a mutable class sendable when reference semantics are unavoidable. final prevents subclassing from introducing new mutable state. private var name ensures external code cannot bypass the queue. queue.async serializes all writes. Without queue.async, two concurrent calls to updateName would produce a data race on name.

  4. await manager.updateDatabase(userInfo: info) — The await is required because updateDatabase is isolated to CurrentUserManager's actor. Passing info here crosses an isolation boundary — the compiler checks that MyClassUserInfo is Sendable before allowing it.

  5. Empty .task body — This is intentional for the lesson: the focus is the type declarations and the actor boundary at the call site, not the view interaction. In a real app you would call await viewModel.updateCurrentUserInfo() here.

Common Mistakes

Mistake: Applying @unchecked Sendable to silence a warning without adding any synchronization.
This is the most dangerous misuse. @unchecked Sendable silences the compiler but does nothing to prevent a data race. If two threads simultaneously write to the same property of your class, you will get undefined behavior — corrupted data, crashes, or intermittent bugs that are nearly impossible to reproduce under a debugger. Always add a lock, a serial queue, or actor isolation before using @unchecked Sendable.

Mistake: Passing a mutable class instance into a Task or across an actor boundary without Sendable.
In Swift 5 with default concurrency checking, this may only produce a warning. In Swift 6 strict mode it becomes a compiler error. Start treating these warnings as errors now to avoid a painful migration later. The fix is almost always to convert the type to a struct or wrap it in an actor.

Mistake: Ignoring Sendable warnings because the app appears to work correctly.
Data races are non-deterministic. A data race that does not crash in your testing environment can crash under different timing on a user's device. Swift's Sendable checking is static analysis that can catch these bugs before they reach production. Treat every Sendable-related warning as a potential race condition.

Key Takeaways

  • Sendable is Swift's compile-time guarantee that a value can cross concurrency boundaries without introducing shared mutable state.
  • Value types (struct, enum) with all-Sendable properties are automatically Sendable; prefer them for types that travel between actors or tasks.
  • @unchecked Sendable shifts the thread-safety responsibility entirely to you — it must be backed by a concrete synchronization mechanism, or it is a data race waiting to happen.

Last updated: June 27, 2026

Released under the MIT License.