PEP 567 -- Context Variables: The End of Thread-Local Headaches in Async Python

When Python 3.5 introduced async/await in 2015, it opened the door to powerful concurrent programming patterns. But it also quietly exposed a deep flaw in how Python managed state: threading.local() -- the go-to solution for per-execution-context storage -- simply did not work correctly when multiple asynchronous tasks shared a single OS thread. PEP 567 solved this problem, and it remains one of the most underappreciated additions to the standard library.

The Problem: Why threading.local() Breaks in Async Code

Consider how a web framework might track the current HTTP request using thread-local storage:

import threading

_request_local = threading.local()

def set_current_request(request):
    _request_local.current = request

def get_current_request():
    return _request_local.current

In a traditional multi-threaded server, this works perfectly. Each thread handles one request at a time, so threading.local() neatly isolates each request's data to its own thread.

Now imagine the same code running inside an asyncio event loop. Multiple coroutines -- each handling a different HTTP request -- execute concurrently on the same OS thread. They interleave at every await point. The moment one coroutine sets _request_local.current to Request A, another coroutine could resume, call get_current_request(), and receive Request A instead of its own Request B. The state has "bled" across task boundaries.

Warning

As PEP 567 explained, thread-local variables are simply insufficient for asynchronous tasks that execute concurrently in the same OS thread. Any context manager that saves and restores a context value using threading.local() will have its values bleed to other code unexpectedly when used in async/await code. This affects context managers with implicit state (like decimal contexts and numpy.errstate), request-related data in web applications, and profiling/tracing/logging in large code bases.

The Origin Story: From PEP 550 to PEP 567

PEP 567 did not emerge in isolation. Its direct predecessor was PEP 550, also authored by Yury Selivanov, titled "Execution Context." PEP 550 was far more ambitious in scope, aiming to solve context isolation not only for async tasks, but also for generators and async generators. However, due to its breadth and lack of general consensus on some aspects, it was withdrawn.

On the python-dev mailing list on December 12, 2017, Selivanov introduced PEP 567 as its replacement. Guido van Rossum -- then still Python's BDFL -- suggested that the phrasing be changed from saying PEP 567 "builds upon" PEP 550 to saying it is "a simplified version of PEP 550," since the former implied readers needed to understand PEP 550 first. Selivanov agreed, and the distinction stuck.

Victor Stinner expressed support for this pragmatic approach, writing that he liked "the idea of starting with something simple in Python 3.7" and extending it later once the first implementation was battle-tested. Eric Snow called it "definitely easier to follow conceptually than PEP 550" and noted that having a stdlib implementation would "help open up clean solutions in a number of use cases that are currently addressed in more error-prone ways."

PEP 567 was accepted by Guido van Rossum on Monday, January 22, 2018. In his acceptance message, he wrote that there had been "useful and effective discussion on several of the finer points" and that the team had "arrived at a solid specification, where every part of the design is well motivated." The reference implementation was merged into CPython the same day.

The contextvars API

The contextvars module introduced in Python 3.7 revolves around three classes and one function: ContextVar, Token, Context, and copy_context().

ContextVar: Declaring and Accessing Context Variables

A ContextVar is the key through which you read and write values in the current context. You declare one at module level and interact with it via .get() and .set():

from contextvars import ContextVar

# Declare a context variable with an optional default
current_user: ContextVar[str] = ContextVar('current_user', default='anonymous')

# Read the current value
print(current_user.get())  # 'anonymous'

# Set a new value; returns a Token
token = current_user.set('alice')
print(current_user.get())  # 'alice'

# Reset to the previous value using the token
current_user.reset(token)
print(current_user.get())  # 'anonymous'
Note

The official Python documentation emphasizes an important constraint: "Context Variables should be created at the top module level and never in closures." This is because Context objects hold strong references to context variables, and creating them dynamically inside functions or closures prevents them from being garbage collected.

If no default is provided and the variable has not been set in the current context, calling .get() without a fallback argument raises a LookupError:

session_id: ContextVar[str] = ContextVar('session_id')

try:
    session_id.get()
except LookupError:
    print("No session ID set in the current context")

Token: The Reset Mechanism

Every call to ContextVar.set() returns a Token object. This token captures the variable's previous value (or Token.MISSING if it had none) and can be passed to ContextVar.reset() to restore the original state.

from contextvars import ContextVar

var: ContextVar[int] = ContextVar('var')

# var has no value yet
token = var.set(42)
print(token.old_value)  # Token.MISSING

# Now set it again
token2 = var.set(100)
print(token2.old_value)  # 42

# Reset to value before token2 was created
var.reset(token2)
print(var.get())  # 42

A token can only be used once. Attempting to reset with the same token a second time raises a RuntimeError. This prevents subtle bugs where a context variable could be inadvertently reset multiple times.

Pro Tip

Starting in Python 3.14, tokens support the context manager protocol, providing a much cleaner syntax: with var.set('new value'): automatically resets the variable when the block exits. This eliminates the need for manual token/reset calls in straightforward use cases.

Context and copy_context(): Isolating Execution State

A Context is an immutable mapping from ContextVar objects to their values. You cannot get a direct reference to the current thread's active Context; instead, you call copy_context() to get a shallow copy of it. To execute code within a specific context, you use Context.run():

import contextvars

var = contextvars.ContextVar('var', default='original')

def modify_context():
    var.set('modified')
    print(f"Inside ctx.run: {var.get()}")  # 'modified'

# Snapshot the current context
ctx = contextvars.copy_context()

# Run a function inside the copied context
ctx.run(modify_context)

# Back in the original context, nothing changed
print(f"Outside ctx.run: {var.get()}")  # 'original'

This isolation is the core mechanism that makes context variables safe for concurrent async tasks. The Context implements collections.abc.Mapping (read-only), not MutableMapping. The PEP explicitly rejected making Context mutable because, if Python later added generator-level context isolation, a mutable Context would become a chain-map of context variable mappings, and direct mutation operations would only affect the topmost mapping.

How asyncio Uses It

When a Task is created, it captures a snapshot of the current context. Every time the task's coroutine advances (at each await point), asyncio runs it inside that captured context:

import asyncio
from contextvars import ContextVar

request_id: ContextVar[str] = ContextVar('request_id')

async def handle_request(rid: str):
    request_id.set(rid)
    # Simulate some async I/O
    await asyncio.sleep(0.1)
    # After resuming, the context variable still has our value
    print(f"Handling {request_id.get()}")

async def main():
    # These tasks run concurrently on the same thread,
    # but each sees its own request_id value
    await asyncio.gather(
        handle_request("REQ-001"),
        handle_request("REQ-002"),
        handle_request("REQ-003"),
    )

asyncio.run(main())
# Output (order may vary):
# Handling REQ-001
# Handling REQ-002
# Handling REQ-003

No bleeding. No race conditions. Each task's context is fully isolated. The asyncio event loop methods call_soon(), call_later(), call_at(), and Future.add_done_callback() all accept an optional context keyword argument so that callbacks run in the context they were scheduled from.

Under the Hood: HAMT and Performance

A critical design requirement for context variables was performance. The copy_context() function needs to be called constantly -- every time a task is scheduled, every time a callback fires. If copying a context required duplicating a dictionary, the overhead would be prohibitive.

The solution is a data structure called a Hash Array Mapped Trie (HAMT), borrowed from functional programming languages like Clojure and Scala. HAMTs provide O(1) copy operations through structural sharing: when you "modify" an immutable HAMT, you get a new trie that shares nearly all of its nodes with the original, creating only the handful of nodes that differ.

This allows copy_context() to run in O(1) time, while ContextVar.set() runs in O(log N) time, where N is the number of items in the context. In addition, ContextVar.get() uses an internal cache for the most recently accessed value, allowing it to bypass the hash lookup entirely in the common case.

Offloading Work to Threads: A Practical Pattern

One pattern that comes up regularly is running synchronous code in a thread pool while preserving the current context. Without explicit handling, a thread started by ThreadPoolExecutor will not inherit the calling coroutine's context variables:

import contextvars
from concurrent.futures import ThreadPoolExecutor

var = contextvars.ContextVar('var')
var.set('important_value')

def sync_work():
    print(var.get())  # What prints here?

# Without context propagation:
executor = ThreadPoolExecutor()
# This would raise LookupError or return a default,
# because the new thread has its own empty context

# With context propagation:
ctx = contextvars.copy_context()
executor.submit(ctx.run, sync_work)  # Prints: 'important_value'

By capturing the context with copy_context() and running the function via ctx.run(), you ensure the worker thread sees the same context variable values as the calling code.

Related PEPs: The Bigger Picture

PEP Title Status Relationship to PEP 567
PEP 550 Execution Context Withdrawn Ambitious predecessor; its HAMT data structure and API concepts were carried into PEP 567
PEP 568 Generator-sensitivity for Context Variables Deferred Proposed extending PEP 567 to give generators their own isolated context; remains deferred
PEP 603 Adding a frozenmap type to collections Draft Proposed exposing the HAMT that already powers contextvars as a public frozenmap type

Real-World Usage: Where Context Variables Shine

Web frameworks use them extensively. If you work with FastAPI, Starlette, or similar ASGI frameworks, context variables are often how per-request state (like database sessions, authentication tokens, or trace IDs) is propagated through middleware and route handlers without cluttering every function signature.

Structured logging is another natural fit. Libraries can set a trace ID or correlation ID as a context variable at the start of a request, and every log statement within that request's execution can automatically include it -- even across await boundaries.

The decimal module was one of the original motivations for the entire effort. The decimal context (precision, rounding mode, etc.) uses thread-local storage, which breaks in async code. While decimal itself hasn't been migrated to contextvars in the standard library, the path forward is clear, and libraries that wrap decimal behavior can and do use contextvars for correct async behavior.

"We needed an equivalent of thread local storage for async/await, that's how we ended up proposing and implementing Python's contextvars." — Yury Selivanov, October 2021

Common Pitfalls and Best Practices

Declare context variables at module level. Never create a ContextVar inside a function, loop, or closure. Context objects hold strong references to context variables, so dynamically created ones cannot be garbage collected.

Always use the Token to reset. Don't try to "undo" a set() call by manually setting the old value. Use token = var.set(value) and var.reset(token) (or the context manager syntax in Python 3.14+). This correctly handles the case where the variable had no value before.

Beware of connection pools and caches. Ben Darnell's warning from the original python-dev discussion is still relevant: object lifetimes that extend beyond a single request or task (like pooled database connections) can capture context in surprising ways. A database client with a connection pool may hang on to the context from the request that created the connection instead of picking up a new context for each query.

Don't confuse contextvars with context managers. Despite sharing the word "context," they are fundamentally different concepts. A context manager (with statement) manages resource lifecycle. A ContextVar manages state scoped to an execution context. They can be used together, and often should be, but they are not the same thing.

Key Takeaways

  1. threading.local() breaks in async code: Multiple coroutines sharing a single OS thread will see each other's thread-local values at every await point. PEP 567's contextvars module solves this by providing per-task state isolation.
  2. The API is minimal by design: ContextVar for declaring variables, Token for resetting them, Context and copy_context() for isolating execution state. PEP 567 was deliberately scoped down from the more ambitious PEP 550.
  3. asyncio integrates automatically: Every Task captures a context snapshot at creation and runs inside it. No manual plumbing required for standard async/await code.
  4. Performance is built in: The HAMT data structure enables O(1) context copies and O(log N) variable sets, with internal caching that makes ContextVar.get() nearly free in the common case.
  5. Python 3.14 added context manager support: with var.set('value'): automatically resets the variable when the block exits, eliminating manual token management in straightforward use cases.

PEP 567 solved a real and painful problem in Python's async ecosystem with a design that was deliberately minimal, forward-compatible, and performant. The PEP's acceptance was one of the last major decisions Guido van Rossum made as BDFL before stepping down later that same year, and it represents the Python community's decision-making process at its best: an ambitious idea (PEP 550) was proposed, community feedback revealed it was too broad, and a focused, practical alternative (PEP 567) was developed, debated, refined, and accepted in just over five weeks.

If you're writing async Python and haven't explored contextvars, you're likely either reinventing it or silently tolerating the bugs its absence creates. It's one of those standard library modules that, once you understand it, you'll wonder how you ever worked without it.