python contextvars vs thread local: Which to Use?
Compare python contextvars vs thread local for passing context across threads and async tasks. Understand propagation, isolation, and when each fits.
python contextvars vs thread local requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
The Problem: Passing Context Through Python Code
In many Python applications, you need to carry request-scoped data—such as a request ID, a user ID, or a database connection—through multiple layers of code without adding it to every function signature. Passing these values explicitly becomes unwieldy when the call chain is deep or when third-party libraries sit between your entry point and the code that needs the data.
Two standard library mechanisms solve this: threading.local and contextvars.ContextVar. They look similar at first glance, but they behave very differently under concurrency. Choosing the wrong one can lead to data leaking between requests, or context not being available where you expect it.
The decision between python contextvars vs thread local comes down to how your program is structured: whether you use threads, async tasks, or a mix of both.
Thread-Local Storage: How threading.local Works
threading.local creates an object whose attributes are stored per thread. Each thread sees its own copy of the data, and changes made in one thread are invisible to others.
import threading local = threading.local() def set_user(user_id): local.user_id = user_id def get_user(): return getattr(local, 'user_id', None)
When you set local.user_id inside a thread, that value is bound to the current thread. If the same thread later calls get_user, it retrieves the value. But if a new thread is spawned, it starts with an empty threading.local—no data is inherited from the parent thread.
This behavior is ideal for thread-based concurrency where each thread handles a separate request. However, it fails when you use asyncio. In an async program, many tasks run on the same thread, interleaving their execution. threading.local cannot distinguish between tasks because they share the same thread. A value set by one task would be visible to another task that runs on the same thread, causing cross-request contamination.
Contextvars: How ContextVar Works
contextvars.ContextVar was introduced in Python 3.7 to address the async gap. A ContextVar holds a value that is associated with a specific execution context. In asyncio, each task has its own context, and when a task is created, it copies the current context. This means a ContextVar set in a parent task is automatically propagated to child tasks.
import contextvars request_id = contextvars.ContextVar('request_id', default=None) def set_request_id(rid): request_id.set(rid) def get_request_id(): return request_id.get()
When you call request_id.set(value), the change is local to the current context. In a threaded program, each thread has its own context, so ContextVar also works across threads. But its real advantage is in async code: when you create a new task with asyncio.create_task(), the task inherits a snapshot of the current context. Any changes made inside the child task do not affect the parent, and each task maintains its own isolated value.
Key Differences: Propagation, Isolation, and Async Support
The most important difference is how data flows across concurrency boundaries.
| Aspect | threading.local | contextvars.ContextVar |
|---|---|---|
| Scope | Per thread | Per execution context (task, thread) |
| Propagation to child threads | No | No (new thread gets a fresh context) |
| Propagation to async tasks | No (same thread, so shared) | Yes (child tasks inherit a copy) |
| Isolation between async tasks | None | Full isolation |
| Thread safety | Yes (per-thread) | Yes (per-context) |
In a threaded application, both mechanisms provide isolation between threads. But in an async application, threading.local is dangerous because all tasks share the same thread. A value set in one task remains visible to other tasks that run on the same thread, even if they are logically separate requests.
ContextVar solves this by associating values with the task's context. When an async task yields control, its context is saved, and when another task runs, it uses its own context. This gives you the same isolation that threads provide, but for cooperative multitasking.
Another difference is how values are reset. With threading.local, you typically delete the attribute when the request ends. With ContextVar, you can use token = var.set(value) and later call var.reset(token) to restore the previous value, which is useful for nested contexts.
When to Use Thread-Local Storage
Thread-local storage is still the right choice for applications that use threads as the primary concurrency model. If you are writing a traditional multi-threaded server, a thread pool, or a library that runs in a thread, threading.local gives you simple per-thread storage with minimal overhead.
It is also appropriate when you need to store data that is genuinely tied to the OS thread, such as a thread-specific resource handle or a connection that cannot be shared. Because threading.local is a plain object, you can attach any attributes without defining a new class.
However, you must avoid it in async code. If your application uses asyncio, even partially, threading.local can cause subtle bugs. A common symptom is that a value set in one request appears in another request that happens to run on the same thread at a different time.
When to Use Contextvars
Use contextvars when your application is built on asyncio or when you need context to flow across async tasks. It is the standard way to propagate request-scoped data in modern Python web frameworks. For example, FastAPI and Starlette use contextvars to store request state that is accessible from anywhere in the request handling chain.
Contextvars are also useful in libraries that want to provide a "current" object without requiring the caller to pass it explicitly. For instance, a logging library might store the current request ID in a ContextVar so that log messages automatically include it.
Even in a thread-based application, contextvars can be used instead of threading.local because each thread has its own context. The API is more explicit about resetting values, which can reduce bugs. The main downside is that contextvars is a bit more abstract and may be unfamiliar to developers who are used to threading.local.
Performance and Overhead Considerations
Both mechanisms have runtime costs, but they are generally small compared to the work they enable. threading.local uses a dictionary lookup per attribute access, which is fast but not free. ContextVar also uses a lookup in the current context, and setting a value involves creating a token and updating the context.
The overhead of ContextVar is slightly higher because it must manage the context object and handle propagation when tasks are created. However, the Python core team has optimized this path, and for most applications the difference is negligible.
A more important consideration is memory. threading.local holds values for the lifetime of the thread, so if a thread is reused for many requests, you must explicitly clean up attributes to avoid retaining data. ContextVar values are tied to the context, which is discarded when the task completes, so they are less likely to leak.
If you are in a high-throughput async server, contextvars is the safer choice because it avoids the risk of cross-request contamination that threading.local introduces.
A Practical Example: Request ID Propagation
Let's see how the two mechanisms behave in a small async program. We'll simulate a request handler that spawns a subtask and logs the request ID.
import asyncio import contextvars import threading # Using contextvars request_id_var = contextvars.ContextVar('request_id', default='none') async def handle_request(rid): request_id_var.set(rid) await asyncio.sleep(0) # yield control print(f'Contextvars: request {request_id_var.get()}') async def main_contextvars(): await asyncio.gather( handle_request('A'), handle_request('B'), ) asyncio.run(main_contextvars())
When you run this, each task prints its own request ID because the context is copied when the task is created.
Now try the same with threading.local:
local = threading.local() async def handle_request_threadlocal(rid): local.request_id = rid await asyncio.sleep(0) print(f'Thread local: request {local.request_id}') async def main_threadlocal(): await asyncio.gather( handle_request_threadlocal('A'), handle_request_threadlocal('B'), ) asyncio.run(main_threadlocal())
In this case, both tasks may print the same request ID because they share the same thread. The second task overwrites the value before the first task resumes, leading to incorrect output. This is a classic failure mode.
The fix is to use contextvars for any code that runs under asyncio. For thread-based concurrency, threading.local remains a valid option, but you must be careful to clean up state when a thread finishes its work.