Which tasks inherit a @TaskLocal value, and when is it a better tool than a parameter?
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?