scoped values javascoped values java
HappyCoders Glasses

Scoped Values in Java Explained & Compared to ThreadLocal

Sven Woltmann
Sven Woltmann
Last update: June 9, 2026

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 ScopedValue class?
  • How are Scoped Values inherited?
  • What is the difference between ScopedValue and ThreadLocal?

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 created as public static fields, so they can be retrieved from any method.

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;
    } 
}Code language: Java (java)

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 that can be accessed from anywhere.

This works as follows:

We create a static field of type ScopedValue in a publicly accessible place. 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 final static 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));
        // . . .
    }
}Code language: Java (java)

Up to and including Java 23, we can alternatively use the convenience method runWhere() and pass the Runnable to this method as a third parameter:

ScopedValue.runWhere(
    RequestContext.LOGGED_IN_USER,
    loggedInUser,
    () -> restAdapter.processRequest(request) // ⟵ 3rd Parameter
);


This variant was removed in Java 24 to make the ScopedValue API completely “fluent”.

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);
        // . . .
    }
}Code language: Java (java)

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;
    }
}Code language: Java (java)

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.

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"));Code language: Java (java)

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 static interface CallableOp<T, X extends Throwable> {
    T call() throws X;
}Code language: Java (java)

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 {
    . . .
}Code language: Java (java)

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
    . . .
}Code language: Java (java)

And if the called method does not throw an exception, we don't need to catch any.

In Java 21 and 22, the CallableOp interface did not yet exist. Instead, a Callable was used. However, the call() method of the Callable interface throws a generic Exception:

@FunctionalInterface
public interface Callable<V> {
  V call() throws Exception;
}


And thus, in Java 21 and 22, when calling ScopedValue.call(), we always had to catch Exception – even if the called method could only throw a specific exception or none at all.

Until Java 23, we could alternatively use the convenience method callWhere(). This method was removed in Java 24 along with runWhere() (see above).

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);
        // . . .
    }
}Code language: Java (java)

Here you can also see how we use call(...) instead of run(...) and pass a Callable (i.e., a method with a return value) instead of a Runnable.

Inheriting Scoped Values

Scoped Values are automatically inherited by all child threads created via a Structured Task Scope.

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 {
    // . . .
    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();
      // . . .
    }
  }
}Code language: Java (java)

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.

Unlike Scoped Values, Structured Concurrency has not yet been finalized in the current Java 26 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?

Scoped Values have the following advantages:

  • They are only valid during the lifetime of the Runnable passed to the run(...) method, and they are released for garbage collection immediately afterward (unless further references to them exist). A thread-local value, on the other hand, remains in memory until either the thread is terminated (which may never be the case when using a thread pool) or it is explicitly deleted with ThreadLocal.remove(). Since many developers forget to do this (or don't do it because the program is so complex that it's not obvious when a thread-local value is no longer needed), memory leaks are often the result.
  • A Scoped Value is immutable – it can only be given a new value for a new scope through the rebinding described above. Data therefore always flows in the direction of the call chain. This significantly improves the readability and maintainability of the code compared to thread locals, which can be changed at any time via set().
  • The child threads created by StructuredTaskScope have access to the Scoped Value of the parent thread. If, on the other hand, we use InheritableThreadLocal, its value is copied to each child thread so that a child thread cannot change the thread-local value of the parent thread. This can significantly increase the memory footprint.

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:

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>Code language: plaintext (plaintext)

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.