Which tasks inherit a @TaskLocal value, and when is it a better tool than a parameter?

10.8.5 · L3 · Interview

Predict first

Which of these closures see req-1?

enum Ctx { @TaskLocal static var requestID = "none" }
func show(_ tag: String) { print(tag, Ctx.requestID) }

await Ctx.$requestID.withValue("req-1") {
    show("body")
    async let a: Void = { show("async let") }()
    await a
    await Task { show("Task {}") }.value
    await Task.detached { show("detached") }.value
    await withTaskGroup(of: Void.self) { g in g.addTask { show("group child") } }
}
show("outside")
  • body, async let, Task {} and group child see req-1; detached and outside see none
  • every line prints req-1
  • only the body prints req-1

Mental model. A name badge clipped on when you enter the building: everyone who walks in with you can read it, but a person who arrives by a side door has none.

A @TaskLocal value is bound for the dynamic extent of a closure with withValue, and it is inherited by child tasks created inside it: async let, task-group children and an ordinary Task { } all see req-1. A detached task does not, so it sees the default (none), and once the closure ends the value is back to the default (“outside”). That is the whole rule: inheritance follows structure (and Task { } deliberately copies the context), and Task.detached deliberately starts clean.

Use task-locals for ambient context that should flow through code you do not want to thread a parameter through: a request or trace id for logging, a correlation id, the current user for an audit log, a test’s dependency overrides (Swift Testing and swift-log style metadata use this). Do not use them for ordinary data flow: the dependency is invisible at call sites, hard to see in review, and lost the moment someone writes Task.detached or hops to a callback API. Prefer an explicit parameter or a stored dependency when the value is a real input, and treat a task-local as optional context that code must be able to run without.

Child tasks inherit the binding; detached tasks and code outside the closure do not.

Common traps

  • Expecting a detached task to see the value. Detached starts with no task-locals, no actor and no priority.
  • Using a task-local as a hidden parameter for required data. Missing context then becomes a silent default.

They might follow up with

  • Does a value survive a hop through a GCD callback? No; the callback is not part of the task, so capture what you need explicitly.
  • Can children change the parent’s value? No; bindings are scoped, and a child can only bind a new value for its own scope.

Related questions

  • 10.8.4 How does cancellation actually reach your code: what do checkCancellation, isCancelled…
  • 10.8.1 In what order do task group results arrive, and how do you limit how many children run at once?
  • 10.3.3 How does cancellation work in Swift concurrency, and what are task priorities and task locals for?
  • 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?

← How does cancellation actually reach your code: what do…