
Scoped Values were developed – together with Virtual Threads and Structured Concurrency – in Project Loom. They have been included in the JDK since Java 20 as an incubator feature and since Java 21 as a preview feature. They were finalized in Java 25.
In this article, you will learn:
- What is a Scoped Value?
- How to use the
ScopedValueclass? - How are Scoped Values inherited?
- What is the difference between
ScopedValueandThreadLocal?
What is a Scoped Value?
Scoped Values are a form of implicit method parameters that allow one or more values (i.e., arbitrary objects) to be passed to one or more faraway methods without having to add them as explicit parameters to each method in the call chain.
Scoped Values are usually declared as static final fields. The field’s visibility doubles as access control: only code that can see the field can read the value. That is why I recommend declaring the field as narrowly as possible – private or package-private if only one class or one package needs the value. In the example below, it is public because the value is read in a different class than the one that binds it.
If multiple threads use the same ScopedValue field, then it may contain a different value from the point of view of each thread.
If you are familiar with ThreadLocal variables, this will sound familiar. In fact, Scoped Values are a modern alternative to thread locals.
I can best explain Scoped Values with an example.
ScopedValue Example
A classic usage scenario is a web framework that authenticates the user on an incoming request and makes the logged-in user’s data available to the code that processes the request.
That can be done, for example, using a method argument.
Now, in complex applications, the processing of a request can extend over hundreds of methods – but the information about the logged-in user may only be required in a few methods. Nevertheless, we would have to pass the user through all methods that eventually lead to invoking a method for which the logged-in user is relevant.
In the following example, the logged-in user is passed from the Server through the RestAdapter and UseCase to the Repository, where it is eventually evaluated:
class Server {
private void serve(Request request) {
// . . .
User user = authenticateUser(request);
restAdapter.processRequest(request, user);
// . . .
}
}
class RestAdapter {
public void processRequest(Request request, User loggedInUser) {
// . . .
UUID id = extractId(request);
useCase.invoke(id, loggedInUser);
// . . .
}
}
class UseCase {
public void invoke(UUID id, User loggedInUser) {
// . . .
Data data = repository.getData(id, loggedInUser);
// . . .
}
}
class Repository {
public Data getData(UUID id, User loggedInUser) {
Data data = findById(id);
if (loggedInUser.isAdmin()) {
enrichDataWithAdminInfos(data);
}
return data;
}
}
The additional loggedInUser parameter makes our code noisy quite quickly. Most of the methods do not need the user at all – and there might even be methods that should not be able to access the user at all for security reasons.
And what if, at some point deep in the call stack, we also needed the user’s IP address and a trace ID? Then we would have to pass two more arguments through countless methods.
The alternative is to store the user in a Scoped Value.
This works as follows:
We create a static field of type ScopedValue – here in a class of its own, RequestContext, so that Server and Repository can access it. With ScopedValue.where(…), we bind the Scoped Value to the concrete user object; and to the run() method, we supply – in the form of a Runnable – the code during whose execution the Scoped Value should be valid:
public class RequestContext {
public static final ScopedValue<User> LOGGED_IN_USER = ScopedValue.newInstance();
}
class Server {
private void serve(Request request) {
// . . .
User loggedInUser = authenticateUser(request);
ScopedValue.where(RequestContext.LOGGED_IN_USER, loggedInUser)
.run(() -> restAdapter.processRequest(request));
// . . .
}
}
We can then remove the loggedInUser parameter from all method signatures:
class RestAdapter {
public void processRequest(Request request) {
// . . .
UUID id = extractId(request);
useCase.invoke(id);
// . . .
}
}
class UseCase {
public void invoke(UUID id) {
// . . .
Data data = repository.getData(id);
// . . .
}
}
And where we need the logged-in user, we can read it with ScopedValue.get():
class Repository {
public Data getData(UUID id) {
Data data = findById(id);
User loggedInUser = RequestContext.LOGGED_IN_USER.get();
if (loggedInUser.isAdmin()) {
enrichDataWithAdminInfos(data);
}
return data;
}
}
That makes the code much more readable and maintainable, as we no longer have to pass the logged-in user from one method to the next but can access it exactly where we need it.
Binding Multiple Scoped Values
If you need the user’s IP address and a trace ID in addition to the user, you bind multiple Scoped Values in one call – each where(…) returns a new, immutable Carrier to which you can add further bindings (assuming a second field TRACE_ID of type ScopedValue<String> in RequestContext):
ScopedValue.where(RequestContext.LOGGED_IN_USER, loggedInUser)
.where(RequestContext.TRACE_ID, traceId)
.run(() -> restAdapter.processRequest(request));
Don’t overdo it, though: Scoped Values are designed for small numbers. Each thread keeps a cache with 16 entries for bound values; only a hit in that cache makes get() as fast as reading a local variable. If you need several values, you are better off bundling them in a record and binding a single Scoped Value to it – which is also what the Javadoc recommends.
What Happens If No Value Is Bound?
A note on get(): If the Scoped Value is not bound in the current thread, get() throws a NoSuchElementException. If you can’t be sure that a value is bound, you have three options:
// Check beforehand whether a value is bound:
if (RequestContext.LOGGED_IN_USER.isBound()) {
User user = RequestContext.LOGGED_IN_USER.get();
}
// Use a fallback value if no value is bound
// (since Java 25, it must no longer be null):
User user = RequestContext.LOGGED_IN_USER.orElse(User.ANONYMOUS);
// Or throw an exception of your own instead:
User user = RequestContext.LOGGED_IN_USER
.orElseThrow(() -> new IllegalStateException("No logged-in user"));
By the way, the fact that orElse(…) no longer accepts null since Java 25 was the only change made during finalization – otherwise, the API was carried over unchanged from Java 24.
Calling a Method with a Return Value
If the called code has a return value, you can call the method call(CallableOp op) after ScopedValue.where() instead of run(Runnable op).
CallableOp is a functional, generic interface defined as follows:
@FunctionalInterface
public interface CallableOp<T, X extends Throwable> {
T call() throws X;
}
The interface includes both the return value and a potentially thrown exception as type parameters. Thus, the compiler can recognize what kind of exception the invocation of call(…) can throw.
So, if we want to call, for example, the following method in the context of a Scoped Value:
Result doSomethingSmart() throws SpecificException {
// . . .
}
Then the compiler recognizes that call() can only throw a SpecificException as well, and we can catch it as follows:
try {
Result result = ScopedValue.where(RequestContext.LOGGED_IN_USER, loggedInUser)
.call(() -> doSomethingSmart());
} catch (SpecificException e) { // ⟵ Catching SpecificException
// . . .
}
And if the called method does not throw an exception, we don’t need to catch any.
Rebinding Scoped Values
ScopedValue has no set(…) method to change the stored value. This is intentional because the immutability of a value makes complex code much more readable and maintainable.
Instead, you can rebind the value for the invocation of a limited code section (e.g., for the invocation of a sub-method). That means that, for this limited code section, another value is visible … and as soon as that section is terminated, the original value is visible again.
For example, our RestAdapter method might want to hide the information about the logged-in user from the extractId(…) method. To do this, we can call ScopedValue.where(…) again and set the logged-in user to null during the sub-method call:
class RestAdapter {
public void processRequest(Request request) {
// . . .
UUID id = ScopedValue.where(RequestContext.LOGGED_IN_USER, null)
.call(() -> extractId(request));
useCase.invoke(id);
// . . .
}
}
Because extractId(…) has a return value, we use call(…) here instead of run(…).
The following diagram shows the nested scopes of our example: each frame is the scope that a where(…).run(…) or where(…).call(…) opens, and inside it are the methods that run in that scope. extractId(…) runs in the scope of the call(…) invocation and sees null; useCase.invoke(…) runs afterwards in the outer scope again and sees the user. In serve() itself, outside of run(…), the value is not bound.
Inheriting Scoped Values
Scoped Values are automatically inherited by all child threads created via a Structured Task Scope. Threads you start yourself – via Thread.ofVirtual().start(…) or through an ExecutorService – do not inherit Scoped Values; there, the value is not bound.
Using StructuredTaskScope, our use case could, for example, call an external service in parallel to the repository method:
class UseCase {
public void invoke(UUID id)
throws InterruptedException, ExecutionException {
// . . .
try (var scope = StructuredTaskScope.open()) {
Subtask<Data> dataSubtask = scope.fork(() -> repository.getData(id));
Subtask<ExtData> extDataSubtask =
scope.fork(() -> remoteService.getExtData(id));
scope.join();
Data data = dataSubtask.get();
ExtData extData = extDataSubtask.get();
// . . .
}
}
}
This way, we can also access the logged-in user from the child threads created via fork(…) using RequestContext.LOGGED_IN_USER.get().
Since the StructuredTaskScope is not completed until all child threads are finished, it fits very well into the concept of Scoped Values.
Since Java 27, scope.join() throws an ExecutionException if a subtask fails – up to Java 26, it was the unchecked FailedException. That is why invoke(…) declares both exceptions.
Unlike Scoped Values, Structured Concurrency has not yet been finalized in the current Java 27 and must be enabled with --enable-preview.
What Is the Difference Between ScopedValue and ThreadLocal?
Those who have solved the requirements of these examples so far with thread locals may now wonder: Why do we need Scoped Values? What can they do that thread locals can’t?
The differences at a glance:
ScopedValue | ThreadLocal | |
|---|---|---|
| Lifetime of the value | until run() or call() ends | until remove() or the thread ends |
| Changing the value | only by rebinding for an inner scope | set() at any time, from anywhere |
| Inheritance to child threads | in a StructuredTaskScope, by reference | InheritableThreadLocal, the map is copied |
| Reading without a binding | NoSuchElementException, orElse(), orElseThrow() | null or initialValue() |
In detail:
- The bound value is only reachable during the execution of the code passed to
run(…)orcall(…), and it is released for garbage collection afterwards (unless further references to it exist). A thread-local value, on the other hand, remains in memory until the thread ends – which may never happen with a thread pool – or untilThreadLocal.remove()is called; forgottenremove()calls are a classic cause of memory leaks. - A Scoped Value is immutable – it can only be rebound for an inner scope, as described above. Data therefore always flows in the direction of the call chain, whereas a thread-local value can be changed at any time and from anywhere via
set(). - Child threads of a
StructuredTaskScoperead the parent thread’s Scoped Value by reference. WithInheritableThreadLocal, on the other hand, the entire map of thread-local entries is copied when each child thread starts, so that aset()in the child thread does not change the parent thread’s binding – with many child threads, that adds up.
Like thread locals, Scoped Values are available for both platform and virtual threads. Especially when there are thousands to millions of virtual child threads, the memory savings from accessing the Scoped Value of the parent thread (instead of creating a copy) can be significant.
History
Scoped Values were defined in the following JDK Enhancement Proposals:
- Java 20: JEP 429: Scoped Values (Incubator)
- Java 21: JEP 446: Scoped Values (Preview)
- Java 22: JEP 464: Scoped Values (Second Preview)
- Java 23: JEP 481: Scoped Values (Third Preview)
- Java 24: JEP 487: Scoped Values (Fourth Preview)
- Java 25: JEP 506: Scoped Values
Using Scoped Values Before Java 25
Since Java 25, Scoped Values are final – you don’t need to do anything else to use them.
If you want to use them as a preview feature in Java 21 to 24, you have to enable preview features explicitly. To do so, call the javac and java commands with the following options:
$ javac --enable-preview --source <Java version> <.java file to compile>
$ java --enable-preview <.java file or compiled class to execute>
Summary
With Scoped Values, we get a very useful construct for providing a thread – and, if needed, a group of child threads – with a read-only, thread-specific value for the duration of their lifetime.
The biggest gain over ThreadLocal isn’t performance, but clarity: a Scoped Value is immutable, its scope can be read directly from the code, and it’s automatically released as soon as the run() or call() block ends. This removes two classic sources of bugs with thread-local variables at once – forgotten remove() calls and hard-to-trace value changes across the call stack.
Scoped Values show their real strength in combination with Virtual Threads and Structured Concurrency: thousands of child threads can read the same value from the parent thread without it having to be copied.
Did this article save you time? Then I’d be happy if you invested a minute of it in a review on my ProvenExpert profile. Your feedback shows me that the work on these articles pays off.




