Skip to content

50. Context Variables

PEP 567 Context and ContextVar objects, context copying on task creation, and asyncio integration.

Context variables provide task-local state for concurrent Python programs. They are implemented by the contextvars module and are designed mainly for asynchronous code, where many logical tasks can run on the same operating system thread.

A context variable looks like a global variable from the caller’s point of view, but its value belongs to the current logical execution context.

This solves a problem that thread-local storage cannot solve cleanly.

import contextvars

request_id = contextvars.ContextVar("request_id")

request_id.set("req-123")

print(request_id.get())

Output:

req-123

The value is not stored globally in the variable object. It is stored in the current context.

50.1 Why Context Variables Exist

Thread-local storage works when each request or unit of work has its own OS thread.

import threading

local = threading.local()

def handle_request(id):
    local.request_id = id
    process()

This breaks down for async code.

In async programs, many tasks share one thread:

one OS thread
    event loop
        task A
        task B
        task C

If all tasks share one thread, then thread-local state is shared by all tasks in that thread.

Context variables attach state to the logical context, not merely the OS thread.

50.2 The Core Problem

Suppose an async server wants each request to have a request ID.

A naive global variable fails:

current_request_id = None

async def handle_request(id):
    global current_request_id
    current_request_id = id
    await do_work()
    return current_request_id

If two requests interleave, one can overwrite the other’s value.

task A sets request_id = A
task A awaits
task B sets request_id = B
task B awaits
task A resumes and sees B

Context variables prevent this by giving each task its own context value.

50.3 Basic ContextVar

Create a context variable:

from contextvars import ContextVar

request_id = ContextVar("request_id")

Set a value:

request_id.set("abc")

Get a value:

print(request_id.get())

If no value exists, get() raises LookupError unless a default is provided.

request_id = ContextVar("request_id", default=None)

print(request_id.get())

Output:

None

50.4 ContextVar Objects Are Keys

A ContextVar object is a key into the current context.

current context
    request_id variable -> "req-123"
    user_id variable    -> "user-9"

The variable object itself does not hold one global value.

This is why the same ContextVar can return different values in different tasks.

request_id.get()

means:

look up request_id in the current context

50.5 Tokens

set() returns a token.

token = request_id.set("req-123")

The token records the previous state for that variable in that context.

Use reset() to restore the previous value:

request_id.reset(token)

This is important for scoped changes.

token = request_id.set("inner")
try:
    run_inner()
finally:
    request_id.reset(token)

Without reset, the value remains changed in the current context.

50.6 Scoped Context Values

A helper function can manage scoped values.

from contextlib import contextmanager
from contextvars import ContextVar

request_id = ContextVar("request_id", default=None)

@contextmanager
def bind_request_id(value):
    token = request_id.set(value)
    try:
        yield
    finally:
        request_id.reset(token)

Usage:

with bind_request_id("req-1"):
    print(request_id.get())

print(request_id.get())

Inside the block, the value is set. After the block, the previous value is restored.

50.7 Async Task Isolation

Context variables are especially useful with asyncio.

import asyncio
from contextvars import ContextVar

request_id = ContextVar("request_id")

async def inner():
    await asyncio.sleep(0)
    print(request_id.get())

async def handle(id):
    request_id.set(id)
    await inner()

async def main():
    await asyncio.gather(
        handle("A"),
        handle("B"),
    )

asyncio.run(main())

Expected output shape:

A
B

Even though both tasks run on the same thread, each task keeps its own context value.

50.8 Context Capture on Task Creation

When an asyncio task is created, it captures the current context.

import asyncio
from contextvars import ContextVar

var = ContextVar("var", default="unset")

async def show():
    print(var.get())

async def main():
    var.set("before task")
    task = asyncio.create_task(show())

    var.set("after task")
    await task

asyncio.run(main())

The task sees:

before task

The context was copied when the task was created.

This behavior is central. Context variables follow logical task creation, not later global mutation.

50.9 Context Propagation

Context propagation means carrying context values across execution boundaries.

In async code, task creation usually captures context.

In threaded code, context is not automatically shared with new threads in the same way.

Example:

from contextvars import ContextVar
import threading

var = ContextVar("var", default="unset")
var.set("main")

def worker():
    print(var.get())

threading.Thread(target=worker).start()

The worker thread may see:

unset

because it has a separate context.

If you want propagation, copy the context explicitly.

50.10 copy_context

Use copy_context() to capture the current context.

from contextvars import copy_context

ctx = copy_context()

Run a callable inside that context:

ctx.run(function, arg1, arg2)

Thread propagation example:

from contextvars import ContextVar, copy_context
import threading

var = ContextVar("var", default="unset")
var.set("main")

ctx = copy_context()

def worker():
    print(var.get())

thread = threading.Thread(target=ctx.run, args=(worker,))
thread.start()
thread.join()

Now the worker runs with the copied context.

50.11 Context Objects

A Context is a mapping-like object containing context variable bindings.

from contextvars import ContextVar, copy_context

var = ContextVar("var")
var.set("value")

ctx = copy_context()

print(ctx)
print(ctx.get(var))

A context stores bindings from ContextVar objects to values.

You usually do not mutate a context directly. You run code inside it.

ctx.run(callable)

50.12 Contexts Are Immutable-Like Snapshots

A copied context behaves like a snapshot of the current bindings.

var.set("A")
ctx = copy_context()
var.set("B")

ctx.run(lambda: print(var.get()))
print(var.get())

Output:

A
B

The copied context preserved A.

Running code inside a context can change that context’s values, but it does not mutate the caller’s current context.

50.13 Context Variables vs Thread Locals

Featurecontextvarsthreading.local()
ScopeLogical contextOS thread
Async task supportGoodPoor
Thread supportExplicit propagationNatural per-thread storage
Main useRequest/task-local stateThread-local caches and state
PropagationCopyable contextBound to thread

Use contextvars for async request state.

Use threading.local() for state genuinely tied to an OS thread.

50.14 Context Variables vs Globals

A global variable has one binding per module.

current_user = None

A context variable has one binding per context.

current_user = ContextVar("current_user")

Global variable problem:

all tasks share one value

Context variable behavior:

each logical context sees its own value

Context variables are still global objects, but their values are context-specific.

50.15 Request Context Example

from contextvars import ContextVar

request_id = ContextVar("request_id", default="-")

def log(message):
    print(f"[{request_id.get()}] {message}")

async def handle_request(id):
    token = request_id.set(id)
    try:
        log("start")
        await do_work()
        log("end")
    finally:
        request_id.reset(token)

Every function called under handle_request can access the request ID without passing it explicitly.

This is useful for logging, tracing, metrics, and request-scoped configuration.

50.16 Tracing Example

Distributed tracing often needs an implicit current span.

current_span = ContextVar("current_span", default=None)

def start_span(name):
    parent = current_span.get()
    span = Span(name=name, parent=parent)
    token = current_span.set(span)
    return span, token

def end_span(token):
    current_span.reset(token)

Nested calls can create span trees without explicitly passing the parent span through every function.

This is one of the main practical uses of context variables.

50.17 Database Session Example

Some frameworks use context variables to store the current request or session.

current_session = ContextVar("current_session", default=None)

def get_session():
    session = current_session.get()
    if session is None:
        raise RuntimeError("no current session")
    return session

Request handler:

async def handle_request():
    session = open_session()
    token = current_session.set(session)
    try:
        await service()
    finally:
        current_session.reset(token)
        session.close()

This avoids passing session through every function, but it also hides a dependency. Use it deliberately.

50.18 Hidden Dependency Cost

Context variables are powerful because they avoid plumbing arguments everywhere.

That same property can make code harder to understand.

This function:

def save_user(user):
    session = get_session()
    session.save(user)

has a hidden dependency on the current context.

A clearer function may be:

def save_user(session, user):
    session.save(user)

Design rule:

use context variables for cross-cutting execution context
avoid them for ordinary business data

Good uses:

request ID
trace span
auth principal
locale
deadline
transaction context
structured logging fields

Poor uses:

ordinary function inputs
core domain data
mutable application state
configuration that should be explicit

50.19 Defaults

A ContextVar can have a default.

var = ContextVar("var", default="unset")
print(var.get())

Or get() can receive a default:

var = ContextVar("var")
print(var.get("fallback"))

Difference:

ContextVar default:
    stored on variable definition

get(default):
    used only for that get call

If neither exists and no value is set, get() raises LookupError.

50.20 Reset Discipline

Always reset scoped values.

Bad:

def handle_request(id):
    request_id.set(id)
    process()

If the same task later handles another unit of work, the previous value may remain.

Better:

def handle_request(id):
    token = request_id.set(id)
    try:
        process()
    finally:
        request_id.reset(token)

Tokens make context variable updates reversible.

50.21 Nested Values

Tokens support nesting.

var = ContextVar("var", default="root")

t1 = var.set("A")
print(var.get())

t2 = var.set("B")
print(var.get())

var.reset(t2)
print(var.get())

var.reset(t1)
print(var.get())

Output:

A
B
A
root

Each token restores the previous value for that context variable.

Reset tokens in reverse order.

50.22 Token Misuse

A token belongs to the context and variable that created it.

Do not use a token with the wrong variable.

a = ContextVar("a")
b = ContextVar("b")

token = a.set(1)
b.reset(token)

This raises an error.

Do not reset the same token twice.

token = a.set(1)
a.reset(token)
a.reset(token)

Tokens are one-use restoration handles.

50.23 Mutable Values

A context variable can store a mutable object.

var = ContextVar("var")
var.set([])
var.get().append(1)

This is legal, but it can be dangerous.

The context binding points to the list. Mutating the list mutates the same object.

For request context, prefer immutable or controlled values.

Better:

var.set(("req-1", "user-2"))

or store an object whose mutation rules are explicit.

Context variables isolate bindings, not all mutations of referenced objects.

50.24 Copying Context With Mutable Values

Copied contexts copy bindings, not deep object graphs.

var = ContextVar("var")

items = []
var.set(items)

ctx = copy_context()
items.append(1)

ctx.run(lambda: print(var.get()))

The copied context sees the same list object.

Output:

[1]

Context copying is shallow with respect to stored values.

Use immutable values when isolation matters.

50.25 Context and Task Groups

When using task groups or similar structured concurrency, child tasks generally inherit the context at creation time.

async def main():
    request_id.set("req-1")

    async with asyncio.TaskGroup() as tg:
        tg.create_task(worker())

The worker sees the request ID captured when the task was created.

If the parent changes the variable later, the already created task does not automatically track that change.

50.26 Context and asyncio.to_thread

asyncio.to_thread is designed to propagate context to the worker thread.

import asyncio
from contextvars import ContextVar

var = ContextVar("var", default="unset")

def blocking():
    print(var.get())

async def main():
    var.set("async-context")
    await asyncio.to_thread(blocking)

asyncio.run(main())

The blocking function sees the async context value.

This makes to_thread safer than manually starting a thread when context propagation matters.

50.27 Context and Executors

Context propagation to executor workers depends on the API and version behavior.

A safe explicit pattern is:

import asyncio
from contextvars import copy_context

async def main():
    loop = asyncio.get_running_loop()
    ctx = copy_context()

    await loop.run_in_executor(None, ctx.run, blocking_function)

This ensures the callable runs inside the copied context.

Use explicit context capture when correctness depends on it.

50.28 Context and Generators

Context variables interact with generators according to where execution occurs.

A generator resumes in the context active when it is advanced.

This can matter when a generator is passed between execution contexts.

For most code, this is not visible. It becomes important in frameworks that schedule generators, coroutines, callbacks, or greenlet-like execution manually.

50.29 Context and Callbacks

Callbacks run in the context chosen by the scheduler.

In asyncio, callbacks scheduled with event loop APIs often capture or use context according to the event loop’s rules.

Framework authors must be careful to preserve context when moving work across callbacks, threads, or queues.

Application authors mostly see this through logging and tracing correctness.

50.30 Context and Greenlets

Some concurrency frameworks use greenlets or coroutines outside standard asyncio.

Context variable support depends on how the framework integrates with Python context management.

Frameworks that switch execution manually must preserve and restore contexts correctly.

This is an implementation responsibility for the scheduler.

50.31 Context Variables in Libraries

Libraries should avoid creating surprising global context requirements.

Good library use:

read current trace context if present
include request ID in logs if present
support explicit argument override
provide clear context manager for binding

Poor library use:

requires hidden context for core correctness
silently mutates context in unrelated functions
stores large mutable objects
fails if context is missing without clear error

A library should make context behavior explicit in its API.

50.32 Context Manager Pattern

A common pattern:

from contextlib import contextmanager
from contextvars import ContextVar

current_user = ContextVar("current_user", default=None)

@contextmanager
def user_context(user):
    token = current_user.set(user)
    try:
        yield
    finally:
        current_user.reset(token)

Usage:

with user_context(user):
    run()

This gives callers a visible boundary for context changes.

50.33 Async Context Manager Pattern

from contextlib import asynccontextmanager
from contextvars import ContextVar

current_request = ContextVar("current_request", default=None)

@asynccontextmanager
async def request_context(request):
    token = current_request.set(request)
    try:
        yield
    finally:
        current_request.reset(token)

Usage:

async with request_context(request):
    await handle()

This pattern works naturally with async request lifetimes.

50.34 Context Variables and Logging

Context variables are useful for structured logging.

request_id = ContextVar("request_id", default="-")

def log(message):
    print(f"request_id={request_id.get()} message={message}")

Inside request handling:

token = request_id.set("req-42")
try:
    log("start")
    await service()
    log("done")
finally:
    request_id.reset(token)

Every nested function can log with the current request ID.

Production logging systems often inject context variables through filters, adapters, or structured log processors.

50.35 Context Variables and Cancellation

Async cancellation can interrupt code.

Always reset context variables in finally.

token = request_id.set("req-1")
try:
    await work()
finally:
    request_id.reset(token)

If work() is cancelled, the finally block still runs.

Without finally, cancelled tasks may leave stale context values in reused execution contexts.

50.36 Context Variables and Testing

Tests using context variables should reset state.

Bad:

def test_one():
    request_id.set("test-one")

This can leak into later tests running in the same context.

Better:

def test_one():
    token = request_id.set("test-one")
    try:
        assert request_id.get() == "test-one"
    finally:
        request_id.reset(token)

A fixture can manage this pattern.

50.37 Context Variables and Performance

Context variables are efficient enough for request context, tracing, and logging.

But they still have cost.

Avoid using them for extremely hot inner-loop data flow when an explicit local variable would be simpler and faster.

Good:

request_id.get()

inside logging or tracing.

Poor:

for item in millions:
    value = current_value.get()
    compute(value, item)

Pass hot-loop values explicitly.

50.38 CPython Internals

At the CPython level, context variables are integrated with thread state and task scheduling.

A thread has a current context.

Async task machinery saves and restores context as tasks are scheduled.

Conceptually:

thread state
    current context

asyncio task
    saved context

when task runs:
    thread state's current context = task context

when task yields:
    task saves updated context

This is why context variables can behave as task-local state on top of a single OS thread.

50.39 Context and Frames

Context variable lookup does not search Python stack frames.

It searches the current context.

This is different from local variables, globals, and closures.

local variable:
    stored in frame

global variable:
    stored in module dictionary

closure variable:
    stored in cell

context variable:
    stored in current Context

This distinction matters when reasoning about execution state.

50.40 Context Propagation Boundaries

Be alert at boundaries where context may or may not propagate:

creating an asyncio task
calling into a thread
using an executor
scheduling callbacks
crossing process boundaries
calling native code
using custom schedulers
using greenlets

When correctness depends on context, test the boundary explicitly.

50.41 Cross-Process Behavior

Context variables do not automatically cross process boundaries.

If you use multiprocessing, subprocesses, or external workers, propagate context explicitly.

Example:

payload = {
    "request_id": request_id.get(),
    "data": data,
}
send_to_worker(payload)

The receiving process can bind the value to its own context.

50.42 Cross-Interpreter Behavior

Subinterpreters have separate interpreter state. Context variables and their values should be treated as interpreter-local unless an explicit communication mechanism transfers data.

Pass neutral data, such as strings or bytes, across interpreter boundaries.

Do not assume context objects or context variable bindings can be shared directly.

50.43 API Design With Context Variables

A good API often supports both explicit and implicit context.

Example:

current_request_id = ContextVar("current_request_id", default=None)

def emit_event(name, *, request_id=None):
    if request_id is None:
        request_id = current_request_id.get()
    write_event(name, request_id=request_id)

This lets callers pass explicit values when needed, while still supporting context-based defaults.

This is better than making hidden context mandatory.

50.44 Common Bugs

BugCauseFix
Value leaks between operationsMissing token resetUse try/finally
Worker thread sees defaultContext not propagatedUse copy_context or asyncio.to_thread
Async tasks see old valueContext captured at task creationSet value before creating task
Mutation leaks across contextsStored mutable objectStore immutable values or copy
Tests affect each otherContext not resetUse fixtures or tokens
Hidden dependencyFunction reads context silentlyAccept explicit argument override
Executor loses tracingContext not copiedRun executor callable under copied context

50.45 Design Rules

Use context variables for execution context, not ordinary data flow.

Set values at clear boundaries, such as request start, task start, or transaction start.

Always reset using tokens.

Prefer immutable values.

Copy context explicitly when crossing thread or scheduler boundaries.

Do not rely on context variables for security isolation.

Provide explicit parameters for important business data.

Use context managers to make scope visible.

50.46 Minimal Mental Model

Use this model:

A ContextVar is a key.

The current Context maps ContextVars to values.

Each thread has a current Context.

Async tasks save and restore their Context as they run.

Creating a task usually copies the current Context.

Setting a ContextVar changes the current Context.

A token restores the previous binding.

Context values do not automatically cross threads, processes, or interpreters unless explicitly propagated.

50.47 Key Points

Context variables provide logical-context-local state.

They solve the problem that thread-local storage cannot solve in async programs.

A ContextVar stores values in the current Context, not on the variable object itself.

set() returns a token that should be reset.

copy_context() captures the current context.

Async tasks generally capture context when created.

Threads require explicit context propagation unless an API does it for you.

Mutable values can still be shared if the binding points to the same object.

Use context variables for request IDs, trace spans, auth context, locale, deadlines, and similar cross-cutting execution state.