Back to Blog
Python

Python ContextVars vs Threading Local: Which to Use

python contextvars vs threading local: Compare contextvars and threading.local for per-request state in Python, covering async task isolation, context copying, and whe...

contextvarsthreading.localasyncioconcurrencypython
A split diagram contrasting thread-local storage in threads with contextvars context propagation across async tasks in Python.

A web server handling concurrent requests needs to keep request-specific values isolated: the authenticated user, a trace ID, the active database transaction. In a threaded server, each thread runs one request at a time, so thread-local storage is a natural fit. In an async server, many requests share a single thread, so the isolation boundary has to move from the thread to the task. That shift is exactly what python contextvars vs threading local comes down to.

What threading.local Provides

threading.local creates an object whose attributes are stored per thread. Two threads setting the same attribute on the same instance see different values.

import threading local = threading.local() def worker(name): local.name = name print(f"{threading.current_thread().name}: {local.name}") t1 = threading.Thread(target=worker, args=("alice",)) t2 = threading.Thread(target=worker, args=("bob",)) t1.start(); t2.start() t1.join(); t2.join()

Each thread gets its own copy of local.name. The instance itself is shared, but the attribute storage is keyed by thread identity. This works well when the concurrency model is purely thread-based and each thread handles exactly one logical unit of work at a time.

What contextvars.ContextVar Provides

ContextVar, added in Python 3.7, stores a value in the current execution context. A context is an immutable mapping of ContextVar objects to their values. You set a value with .set() and read it with .get().

import contextvars request_id = contextvars.ContextVar("request_id", default=None) def handle_request(): request_id.set("req-42") print(request_id.get()) handle_request() print(request_id.get()) # None, outside the context

The default parameter supplies the value when the variable has not been set in the current context. Unlike threading.local, ContextVar does not care about threads. It cares about contexts, which are copied and passed to new tasks.

Why threading.local Breaks in Async Code

The failure mode is subtle. In asyncio, multiple tasks run on the same thread. When a coroutine awaits, the event loop may switch to another task. If that task reads a threading.local attribute set by the first task, it sees the value, because both tasks share the same thread.

import asyncio import threading local = threading.local() async def leak(): print(local.current_user) async def main(): local.current_user = "alice" await asyncio.sleep(0) # yield control await leak() # prints "alice" — wrong task asyncio.run(main())

The value set in main() is visible to leak() even though they are logically separate tasks. ContextVar fixes this because each asyncio.Task receives a copy of the context at creation time.

How Contexts Are Copied and Propagated

When you create a task with asyncio.create_task(), the current context is copied into the new task. The child task sees the values set before the task was created, but changes it makes do not propagate back to the parent.

import asyncio import contextvars var = contextvars.ContextVar("var", default="default") async def child(): var.set("child-value") print("child:", var.get()) async def parent(): var.set("parent-value") task = asyncio.create_task(child()) await task print("parent:", var.get()) # still "parent-value" asyncio.run(parent())

This copy-on-task-creation behavior is what makes contextvars safe for async code. It also works in threaded code: each thread gets its own context, so values do not leak between threads. The same isolation that protects tasks also protects threads.

Overhead and Runtime Behavior

ContextVar.get() involves a dictionary lookup in the current context, which is slightly more expensive than an attribute access on a threading.local object. In most request handlers the difference is negligible. The more significant cost is context copying: every asyncio.create_task() copies the current context. If a context holds many large values, that copy adds measurable overhead. Keep stored values small — request IDs, user objects, transaction handles — rather than large data structures.

Another runtime detail worth knowing: ContextVar.set() returns a token. Calling token.reset() restores the previous value. This is useful when you need to scope a value to a block and guarantee cleanup even if an exception occurs.

import contextvars var = contextvars.ContextVar("var", default="default") token = var.set("temporary") try: print(var.get()) finally: var.reset(token) print(var.get()) # "default"

Choosing Between threading.local and contextvars

Use threading.local when the code is strictly synchronous and thread-based, and there is no chance of asyncio being introduced later. It is the older, more established API and has no context-copying cost.

Use contextvars when:

  • The code runs under asyncio
  • The same state must be visible across async task boundaries
  • You need the state to follow the logical request, not the physical thread
  • You are writing a library that may be used in either threaded or async programs

contextvars is the safer default for new code because it behaves correctly in both models. threading.local is only correct when you can guarantee a thread-per-request execution model.

Compatibility and Migration Notes

contextvars works in threaded code without any special setup. Each thread has an independent context, so code written against ContextVar remains correct if you later switch the server from threads to asyncio. The standard library relies on this: asyncio.Task, logging context filters, and several other components use contextvars internally.

Migration from threading.local to contextvars is mostly mechanical: replace attribute assignment with var.set() and attribute reads with var.get(). The one behavioral difference to watch is propagation. threading.local values are visible to any code on the same thread; ContextVar values are visible only within the same context and its child tasks. Code that relied on sibling threads seeing each other's values will need a different design, because that sharing is exactly what contextvars prevents.

python contextvars vs threading local: Practical Usage and C | RYUSLOG DEV