top of page

What Is Actor Isolation in Swift?

Actor isolation is Swift’s rule that actor-owned mutable state can only be accessed by code running within that actor’s isolation domain.

actor LaunchStore {
    private var launches: [Launch] = []

    func save(_ launch: Launch) {
        launches.append(launch)
    }
}

The array, the save(_:) method and the code executing inside that method are isolated to this particular LaunchStore instance. Code outside the actor must cross the isolation boundary before it can ask the actor to use that state.

The central idea: isolation defines which code has permission to access a piece of mutable state directly.

Isolation Is the Rule That Makes Actors Safe

An actor does not protect state merely because its declaration uses a different keyword. It protects state because the compiler applies isolation rules to its members.

actor LaunchStore {
    var launches: [Launch] = []
}

let store = LaunchStore()

// Error: actor-isolated property cannot be mutated from here.
store.launches.append(spacexLaunch)

The compiler rejects this access before the program runs. The caller is outside store’s isolation domain and therefore has no permission to mutate its array directly.

This changes concurrency safety from a convention into a language rule. A comment saying “only use this property on our serial queue” can be forgotten. Actor isolation is checked at every access the compiler can see.

Each Actor Instance Defines a Boundary

Isolation belongs to an actor instance, not simply to the actor type.

let upcomingStore = LaunchStore()
let historicalStore = LaunchStore()

upcomingStore and historicalStore are separate isolation domains. Code isolated to one instance does not automatically gain direct access to the other instance.

The following is a conceptual diagram:

Task A ─▶ upcomingStore isolation ─▶ upcoming launches

Task B ─▶ historicalStore isolation ─▶ historical launches

The two actor instances can make progress independently. The serial execution rule applies within each boundary, not across every instance of LaunchStore.

Stored State Is Actor-Isolated by Default

An actor’s mutable stored properties belong to that actor instance.

actor LaunchStore {
    private var launches: [Launch] = []
    private var lastUpdated: Date?

    func save(_ launch: Launch) {
        launches.append(launch)
        lastUpdated = Date()
    }
}

Inside save(_:), both properties are directly available. The method is isolated to the same actor, so the compiler knows that this code has entered the correct boundary.

Outside the actor, even a read may require isolated access:

actor LaunchStore {
    var count: Int {
        launches.count
    }
}

let numberOfLaunches = await store.count

A read is still code executing against actor-owned state. It must be coordinated with possible writes.

Actor Methods Inherit the Actor’s Isolation

Instance methods are actor-isolated unless they are explicitly declared otherwise.

actor LaunchStore {
    private var launches: [Launch] = []

    func contains(id: Launch.ID) -> Bool {
        launches.contains { $0.id == id }
    }

    func saveIfNeeded(_ launch: Launch) {
        guard !contains(id: launch.id) else {
            return
        }

        launches.append(launch)
    }
}

saveIfNeeded(_:) can call contains(id:) synchronously. Both methods are isolated to the same actor instance, so the call remains inside one boundary.

From outside the actor, the same method requires a crossing:

await store.saveIfNeeded(spacexLaunch)

await Marks a Possible Isolation Crossing

The method saveIfNeeded(_:) is not declared async. Its body has no suspension point. The external call still requires await because the task may need to wait for permission to enter the actor.

Outside task
     │
     │ await store.saveIfNeeded(...)
     ▼
LaunchStore isolation boundary
     │
     ▼
saveIfNeeded executes against actor-owned state

If the actor is available, the call may proceed without an observable delay. If another eligible job is executing there, the caller can suspend. As always, await means suspension is possible, not guaranteed.

Isolation crossing is a call or access that moves from one isolation domain into another.

Isolation Is About Permission, Not the Current Thread

Actor isolation does not mean “this property belongs to thread 4.” It means “this property belongs to this actor.”

Actor-isolated function
        │ creates eligible work
        ▼
Actor's serial executor
        │ schedules it
        ▼
Available system thread
        │ executes it
        ▼
Processor core

An actor-isolated job can execute on different system threads at different times. Direct access remains safe because Swift preserves the actor boundary, not because every instruction remains attached to one thread.

This is why thread checks cannot replace isolation checks. The actor is the owner. A thread is an execution resource.

Isolation Does Not Mean One Task Owns the Actor

An asynchronous actor method can suspend while waiting for another operation.

actor LaunchStore {
    private var launches: [Launch] = []

    func refresh(using api: LaunchAPI) async throws {
        let downloaded = try await api.fetchUpcomingLaunches()
        launches = downloaded
    }
}

Before the await, the task executes within LaunchStore isolation. While suspended, it does not reserve the actor. Another eligible actor-isolated job can execute.

refresh task:  actor code ── suspended ───────── actor code

other task:                  actor code

Both periods of refresh(using:) are isolated, but the entire asynchronous method is not one uninterrupted transaction.

Isolation Prevents Data Races, Not Logical Mistakes

Because another job can run during a suspension, earlier assumptions may no longer be true when the task resumes.

actor LaunchStore {
    private var selectedAgency: Agency
    private var launches: [Launch] = []

    func refresh(using api: LaunchAPI) async throws {
        let requestedAgency = selectedAgency
        let result = try await api.fetchLaunches(for: requestedAgency)

        guard requestedAgency == selectedAgency else {
            return
        }

        launches = result
    }
}

The actor prevents two jobs from reading and writing its storage simultaneously. It cannot decide whether an older network response is still meaningful. That is an application rule, so the method verifies it after suspension.

This is the beginning of actor reentrancy. For this article, the important distinction is simple: isolation prevents simultaneous unsafe access; it does not freeze actor state across await.

MainActor Uses the Same Isolation Model

MainActor is a global actor, but the compiler applies the same core rule: isolated state can be accessed directly only from code in the same isolation domain.

@MainActor
final class LaunchListFeature: ObservableObject {
    @Published private(set) var launches: [Launch] = []

    func display(_ launches: [Launch]) {
        self.launches = launches
    }
}

A main-actor-isolated function can call display(_:) directly. Code outside MainActor crosses the boundary with await.

func apply(
    _ launches: [Launch],
    to feature: LaunchListFeature
) async {
    await feature.display(launches)
}

The difference is scope. Each normal actor instance creates its own domain. MainActor provides one globally shared domain for main-facing state and operations.

A Task Can Inherit Isolation

A task created while code is actor-isolated can inherit that actor context.

actor LaunchStore {
    private var launches: [Launch] = []

    func scheduleSort() {
        Task {
            launches.sort { $0.date < $1.date }
        }
    }
}

The task closure can access launches because it inherits LaunchStore isolation from scheduleSort(). Creating the task does not discard the boundary or automatically move the operation to a background thread.

This inheritance is useful because asynchronous work can remain connected to the state it is meant to update. It is also why creating Task { } is not an escape hatch from actor isolation.

What nonisolated Means

Sometimes an actor member does not need access to isolated state. It can be declared nonisolated.

actor LaunchStore {
    nonisolated let sourceName: String
    private var launches: [Launch] = []

    init(sourceName: String) {
        self.sourceName = sourceName
    }

    nonisolated func makeLogPrefix() -> String {
        "[\(sourceName)]"
    }
}

A caller can use the nonisolated function without crossing into the actor:

let prefix = store.makeLogPrefix()

Inside makeLogPrefix(), the compiler does not grant access to isolated mutable state.

nonisolated func currentCount() -> Int {
    // Error: launches is actor-isolated.
    launches.count
}

nonisolated removes an actor-isolation requirement. It does not mean “execute in the background.”

Using an isolated Parameter

A function outside an actor can explicitly borrow an actor’s isolation through an isolated parameter.

actor LaunchStore {
    private var launches: [Launch] = []

    func count() -> Int {
        launches.count
    }

    func nextLaunch() -> Launch? {
        launches.min { $0.date < $1.date }
    }
}

func makeSummary(
    for store: isolated LaunchStore
) -> String {
    let count = store.count()
    let next = store.nextLaunch()

    return "\(count) launches. Next: \(next?.name ?? "None")"
}

Inside makeSummary(for:), calls on store are synchronous because the function itself runs in that actor instance’s isolation.

The caller crosses the boundary once:

let summary = await makeSummary(for: store)

An isolated parameter is useful when one operation needs to perform several synchronous actions within one actor boundary. A function can borrow one actor’s isolation at a time.

Values Leaving an Actor Raise a New Question

An actor often returns a value to a task outside its isolation domain.

actor LaunchStore {
    private var launches: [Launch] = []

    func snapshot() -> [Launch] {
        launches
    }
}

let launches = await store.snapshot()

The method access is isolated, but the returned value leaves the actor. Swift therefore needs to know whether that value can safely cross between concurrency domains.

That question is not answered by actor isolation alone. It leads directly to Sendable, which describes values that can safely be transferred across these boundaries.

The Complete Mental Model

For every access to actor-owned state, ask one question: where is the current code isolated?

Code accesses actor member
          │
          ▼
Same actor isolation?
          │
          ├─ yes ─▶ direct access is permitted
          │
          └─ no  ─▶ cross boundary with await
                            │
                            ▼
                   actor's serial executor
                            │
                            ▼
                  isolated access executes

nonisolated members sit outside the actor boundary and cannot freely access isolated state. An isolated parameter places a whole function inside the boundary of the supplied actor.

If isolated code suspends, another actor job may run. The state remains protected from simultaneous access, but it may change before the original task resumes.

What to Remember

  • Actor isolation defines which code may directly access actor-owned mutable state.
  • The compiler enforces the boundary at each access.
  • Isolation belongs to an actor instance, not merely to its type.
  • Actor properties and instance methods are isolated by default.
  • Calls inside the same isolation domain are direct.
  • Calls from outside generally cross the boundary with await.
  • Isolation is an ownership rule, not thread affinity.
  • Suspension allows another isolated job to run, so state can change across await.
  • nonisolated removes isolation; it does not request background execution.
  • An isolated parameter lets a function borrow one actor instance’s isolation.

Frequently Asked Questions

What is the difference between an actor and actor isolation?

An actor is the reference type and owner of state. Actor isolation is the compiler-enforced rule controlling access to that state.

Does actor isolation mean the actor uses one thread?

No. Isolation restricts access through an actor’s serial executor. The system threads that execute actor jobs can change over time.

Why does reading an actor property require await?

A read must be coordinated with possible writes to the same state. From outside the actor, the task may need to suspend before that isolated access can execute.

Does await make the rest of a function actor-isolated?

No. await marks a potential suspension and permits a particular asynchronous call. It does not permanently move all surrounding code into the called actor.

Does nonisolated make a function concurrent?

No. It states that the function does not require the actor’s isolation. It says nothing by itself about parallel execution or background threads.

Can actor state change during an isolated method?

Not simultaneously while one synchronous section is executing. If the method suspends at await, another actor-isolated job may run and change the state before the method resumes.

Continue Learning

Actor isolation controls access to state, but values still need to move between actors, tasks and global actors. The next article, What Is Sendable in Swift?, will explain how Swift describes values that are safe to transfer across concurrency boundaries.

Download the Xcode Playground

Use the accompanying playground to inspect which LaunchStore accesses compile inside and outside the actor. Add a nonisolated logging method, write a helper with an isolated parameter and then return a launch snapshot across the boundary to prepare for the next lesson on Sendable.

bottom of page