top of page

01 · INTRODUCTION

What Are Task-Local Values in Swift?

The short answer

A task-local value is contextual information associated with the current Swift task. Once bound, the value can be read by functions called inside that operation and is inherited by tasks created from the surrounding task context.

CODE EXAMPLE

enum LaunchContext {
    @TaskLocal static var traceID: String?
}

func refreshLaunches() async throws {
    try await LaunchContext.$traceID.withValue("launch-refresh-42") {
        try await loadLaunchDashboard()
    }
}

Any function reached during loadLaunchDashboard() can read LaunchContext.traceID. Child tasks created with async let or a task group inherit the same binding, so logs produced by several concurrent operations can still be connected to one dashboard refresh.

The central rule

A task-local value is scoped context carried by a task. Bind it for an operation, read it deeper in that operation and let Swift restore the previous value when the scope ends.

02 · TUTORIAL

Why Task-Local Values Exist

Imagine that one launch-dashboard refresh calls several layers of code:

EXECUTION DIAGRAM

refreshLaunches()
  └─ loadLaunchDashboard()
       ├─ fetchUpcomingLaunches()
       │    └─ NetworkLogger.logRequest()
       ├─ fetchRockets()
       │    └─ NetworkLogger.logRequest()
       └─ fetchWeather()
            └─ NetworkLogger.logRequest()

You want every log entry to contain the same trace identifier. One solution is to add that identifier to every function:

CODE EXAMPLE

func loadLaunchDashboard(traceID: String) async throws
func fetchUpcomingLaunches(traceID: String) async throws
func fetchRockets(traceID: String) async throws
func fetchWeather(traceID: String) async throws
func logRequest(_ message: String, traceID: String)

Explicit parameters are normally excellent because they make data flow visible. Here, however, the identifier does not change what any function calculates. It is operational metadata needed by logging and tracing throughout the call tree.

A task-local value allows the operation to bind that metadata once. Code deep inside the operation can read it without forcing every intermediate API to accept and forward another parameter.

03 · TUTORIAL

Declare a Task-Local Value with @TaskLocal

A task-local declaration is a static property annotated with @TaskLocal:

CODE EXAMPLE

enum LaunchContext {
    @TaskLocal static var traceID: String?
}

The property type must conform to Sendable because the value can be inherited across task boundaries. An optional is useful when “no context has been bound” is a valid state. Here, the default value is nil.

You read the value like an ordinary static property:

CODE EXAMPLE

struct NetworkLogger {
    static func log(_ message: String) {
        let traceID = LaunchContext.traceID ?? "no-trace"
        print("[\(traceID)] \(message)")
    }
}

Reading works from asynchronous and synchronous functions. The value belongs to the current task context, not to the function declaration that reads it.

Important Terminology

A task-local declaration is a key with a default value. A binding temporarily associates that key with a more specific value in the current execution context.

04 · TUTORIAL

Bind a Value with withValue

You do not assign directly to a task-local property. Instead, access its projected value with $ and call withValue:

CODE EXAMPLE

try await LaunchContext.$traceID.withValue("launch-refresh-42") {
    NetworkLogger.log("Refresh started")
    try await loadLaunchDashboard()
    NetworkLogger.log("Refresh finished")
}

The binding is visible for the duration of the closure. When the closure returns or throws, Swift removes that binding and the previous value becomes visible again.

CODE EXAMPLE

print(LaunchContext.traceID as Any) // nil

await LaunchContext.$traceID.withValue("outer") {
    print(LaunchContext.traceID as Any) // Optional("outer")
}

print(LaunchContext.traceID as Any) // nil

withValue does not create another task. It establishes a scoped binding while the supplied operation executes in the calling context.

05 · TUTORIAL

The Binding Follows the Call Tree

A deeply nested function does not need to receive or forward the trace identifier:

CODE EXAMPLE

func loadLaunchDashboard() async throws -> LaunchDashboard {
    NetworkLogger.log("Loading dashboard")
    return try await dashboardService.load()
}

struct DashboardService: Sendable {
    let launchAPI: LaunchAPI

    func load() async throws -> LaunchDashboard {
        NetworkLogger.log("Requesting launches")
        let launches = try await launchAPI.fetchUpcoming()
        return LaunchDashboard(launches: launches)
    }
}

Both log calls read the value bound by refreshLaunches(), even though neither function has a traceID parameter. Synchronous helper functions called from this path can read it too.

This is dynamic scoping: the visible value depends on the execution context from which the function was called, not merely on where that function was defined.

06 · TUTORIAL

Child Tasks Inherit Task-Local Values

The previous article established that async let and task groups create child tasks. Task-local values are part of the context inherited by those children.

CODE EXAMPLE

func loadLaunchDashboard() async throws -> LaunchDashboard {
    async let launches = fetchUpcomingLaunches()
    async let rockets = fetchRockets()
    async let weather = fetchWeather()

    let values = try await (launches, rockets, weather)
    return LaunchDashboard(
        launches: values.0,
        rockets: values.1,
        weather: values.2
    )
}

If the parent task has "launch-refresh-42" bound, all three child tasks can read that value. Each network layer can log its own activity using the same trace identifier.

Conceptual diagram:

EXECUTION DIAGRAM

Parent task
traceID = "launch-refresh-42"
  │
  ├─ async let launches ── reads "launch-refresh-42"
  ├─ async let rockets  ── reads "launch-refresh-42"
  └─ async let weather  ── reads "launch-refresh-42"

The value follows task context,
not one particular system thread.

This is contextual inheritance, not shared mutable storage. The children can read the inherited binding, but they cannot assign a new value back into the parent's task-local context.

Task Groups Inherit the Same Context

Every child added to a task group inherits the task-local bindings visible at its creation point:

CODE EXAMPLE

func fetchSummaries(
    for identifiers: [String]
async throws -> [LaunchSummary] {
    try await withThrowingTaskGroup(of: LaunchSummary.self) { group in
        for identifier in identifiers {
            group.addTask {
                NetworkLogger.log("Loading \(identifier)")
                return try await fetchSummary(for: identifier)
            }
        }

        var summaries: [LaunchSummary] = []
        for try await summary in group {
            summaries.append(summary)
        }
        return summaries
    }
}

The children may complete in any order, yet their logs retain the context of the larger operation. This is one reason task-local values are useful for tracing concurrent work: the code does not need thread identity or completion order to know which refresh produced each event.

07 · TUTORIAL

Nested Bindings Shadow Earlier Values

The same task-local key can be bound again inside an existing scope. The deeper binding temporarily shadows the earlier one:

CODE EXAMPLE

await LaunchContext.$traceID.withValue("dashboard-42") {
    NetworkLogger.log("Loading dashboard")

    await LaunchContext.$traceID.withValue("weather-42") {
        NetworkLogger.log("Loading detailed weather")
    }

    NetworkLogger.log("Continuing dashboard")
}

The first and third log entries use "dashboard-42". The nested weather operation uses "weather-42". When the nested closure finishes, Swift restores the outer binding automatically.

EXECUTION DIAGRAM

default: nil
  └─ binding: "dashboard-42"
       └─ deeper binding: "weather-42"
       └─ restored: "dashboard-42"
  └─ restored: nil

Bindings behave like a stack of scoped values. This avoids the “set it globally and remember to reset it later” problem.

08 · TUTORIAL

How Unstructured Tasks Inherit Context

Task { } Inherits Task-Local Values

Task { } creates an unstructured task, not a child task. However, it inherits task-local values from the context in which it is created:

CODE EXAMPLE

await LaunchContext.$traceID.withValue("refresh-42") {
    let loggingTask = Task {
        NetworkLogger.log("Recording refresh analytics")
        return LaunchContext.traceID
    }

    print(await loggingTask.value as Any) // Optional("refresh-42")
}

This distinction is worth preserving:

Task { } inherits contextual values;

• its lifetime is still unstructured;

• the surrounding scope does not implicitly await it.

Context inheritance does not turn an unstructured task into a child task. If the task matters to the operation's result or lifetime, prefer a structured construct.

Task.detached Does Not Inherit Them

A detached task begins without the creator's task-local bindings:

CODE EXAMPLE

await LaunchContext.$traceID.withValue("refresh-42") {
    let detached = Task.detached {
        NetworkLogger.log("Detached maintenance")
        return LaunchContext.traceID
    }

    print(await detached.value as Any) // nil
}

Detachment deliberately discards the surrounding task context, including task-local values, priority and actor isolation.

Code reached from a binding
Called async or synchronous function

Inherits the task-local value?
Yes

Code reached from a binding
async let child

Inherits the task-local value?
Yes

Code reached from a binding
Task-group child

Inherits the task-local value?
Yes

Code reached from a binding
Task { }

Inherits the task-local value?
Yes

Code reached from a binding
Task.detached { }

Inherits the task-local value?
No

If detached work genuinely requires a value, pass it explicitly or deliberately create a new binding inside the detached task. Do not assume the original context crossed the detachment boundary.

09 · TUTORIAL

Task-Local Does Not Mean Thread-Local

A Swift task can suspend and later resume on a different system thread. Storing contextual information against one thread would therefore be a poor way to describe an asynchronous operation.

EXECUTION DIAGRAM

Task begins on Thread A
  │ traceID = "refresh-42"
  ▼
suspends at await
  ▼
Task resumes on Thread B
  │ traceID is still "refresh-42"
  ▼
operation continues

Conceptual diagram: the task-local binding follows the task's execution context across suspension. It does not pin the task to a thread, select an executor or control actor isolation.

Task-local answers “which operation does this context belong to?”

It does not answer “which thread is executing?” or “which actor owns this state?”

10 · TUTORIAL

A Complete Launch-Feature Example

The following application-shaped example binds one immutable, Sendable context value at the feature boundary:

CODE EXAMPLE

struct LaunchTrace: Sendable {
    let id: String
    let source: String
}

enum LaunchContext {
    @TaskLocal static var trace: LaunchTrace?
}

struct LaunchDashboardLoader: Sendable {
    let launchAPI: LaunchAPI
    let rocketAPI: RocketAPI
    let weatherAPI: WeatherAPI

    func load(traceID: String) async throws -> LaunchDashboard {
        let trace = LaunchTrace(id: traceID, source: "dashboard")

        return try await LaunchContext.$trace.withValue(trace) {
            NetworkLogger.log("Dashboard refresh started")

            async let launches = launchAPI.fetchUpcoming()
            async let rockets = rocketAPI.fetchRockets()
            async let weather = weatherAPI.fetchForecast()

            let values = try await (launches, rockets, weather)

            NetworkLogger.log("Dashboard refresh finished")

            return LaunchDashboard(
                launches: values.0,
                rockets: values.1,
                weather: values.2
            )
        }
    }
}

The feature's required input, traceID, remains explicit at its public boundary. Inside the operation, the derived tracing context becomes available to logging code and all three child tasks. The dashboard's actual data still flows through ordinary parameters and return values.

This separation is healthy: business data remains visible in function signatures, while cross-cutting diagnostic metadata travels with the task.

11 · TUTORIAL

When Should You Use Task-Local Values?

Good uses are values that describe an operation without determining its logical result:

• trace and correlation identifiers;

• contextual logging metadata;

• instrumentation and diagnostics;

• carefully designed library context that must follow a task tree.

Prefer ordinary parameters for values a function genuinely needs to do its job. A launch identifier, API request, user choice or calculation input should normally remain visible in the function signature.

Do not use task-local values as:

• a general-purpose global variable;

• mutable shared feature state;

• a hidden channel for returning results;

• a replacement for actors or synchronization;

• a way to avoid designing clear dependencies.

Task-local lookup is also less direct than reading a normal local variable or parameter. Read it where the contextual information is needed rather than repeatedly inside a tight loop.

12 · TUTORIAL

Common Misunderstandings

“@TaskLocal creates one global value.”

No. The static declaration identifies the key. Different tasks and nested scopes can observe different bindings for that key at the same time.

“I can assign a new value whenever I want.”

No. Bind a value with $property.withValue. The scoped API ensures that the previous binding is restored.

“A child can update the parent's task-local value.”

No. A child inherits what it can read. It can create a deeper binding for its own operation, but that does not mutate the parent's binding.

“Task-local values make data race protection unnecessary.”

No. They propagate context; they do not isolate mutable state. The stored type must be Sendable, and any shared reference still needs an appropriate concurrency design.

“Task.detached keeps the current trace automatically.”

No. Detached tasks do not inherit task-local values. Any context needed beyond that boundary must be transferred deliberately.


13 · TUTORIAL

What to Remember

• Declare a task-local key as a static property with @TaskLocal.

• Bind a value using $property.withValue; do not assign it directly.

• The binding is scoped and the previous value is restored automatically.

• Called functions, async let children, task-group children and Task { } can inherit the current value.

Task.detached does not inherit task-local values.

• Task-local context follows task execution, not a particular thread.

• Use it for contextual metadata such as tracing and logging.

• Keep required business data explicit in parameters and return values.


14 · TUTORIAL

Frequently Asked Questions

What is a task-local value in Swift?

It is a scoped value associated with the current task context. Functions and inheriting tasks reached within that context can read it without receiving it as a parameter.

How do I set a @TaskLocal value?

You do not assign to the property. Use its projected value: Context.$value.withValue(newValue) { ... }. The value remains bound for that operation.

Do async let and task groups inherit task-local values?

Yes. Their child tasks inherit the bindings visible in the parent when those children are created.

Does Task { } inherit task-local values?

Yes. Task { } inherits them even though it creates an unstructured task. Inheritance does not give it a structured parent–child lifetime.

Does Task.detached inherit task-local values?

No. Detachment discards the surrounding task-local context. Pass or rebind any required value explicitly.

Are task-local values safe mutable storage?

No. They are a mechanism for scoped context propagation. Use actors or another suitable synchronization design for shared mutable state.


16 · TUTORIAL

Continue Learning

The task tree can now carry lifetime, cancellation, priority and contextual values. The next article, What Is Actor Reentrancy in Swift?, returns to actors and explains how another actor job can run while an earlier task is suspended at await.

17 · TUTORIAL

Download the Xcode Playgrounds

Use Understanding Task-Local Values.playground to follow the binding and inheritance examples, then open Task-Local Value Challenges.playground to practise nested scopes, task creation boundaries and appropriate API design.

bottom of page