Skip to content

Measuring the Cost of contextvars

Context variables carry request IDs, deadlines, tenants and sessions through asyncio code, and every task copies the current context when it is created. It is reasonable to ask what that costs before putting more into context. Measured on Python 3.14: ContextVar.get() took 15.7 ns, close to a dict lookup at 13.6 ns and faster than a threading.local attribute at 35.4 ns; set() followed by reset() took 140 ns. copy_context() took 26 ns with one variable set and 36–40 ns with 10, 100 or 1,000 variables set, because contexts are immutable mappings that are shared, not copied, and creating and running a task took 7.4–8.8 µs regardless of how many variables were set. The costs that did matter were elsewhere: reading an unset variable through try/except LookupError took 120 ns against 15–17 ns with a default, and a 50 MiB object placed in a context variable during a request stayed alive after the request ended, for as long as the 10 background tasks it had started were running — and was freed when they finished or were started with an empty context. This guide measures those costs and shows where they come from.

Prerequisites

1. Measure the basic operations

Time the operations your code performs per request with timeit, next to the alternatives you might use instead:

import contextvars, threading, timeit

request_id = contextvars.ContextVar("request_id", default=None)
request_id.set("abc")
local = threading.local(); local.request_id = "abc"
plain = {"request_id": "abc"}

for label, stmt in [
    ("ContextVar.get()", "request_id.get()"),
    ("set() + reset()", "request_id.reset(request_id.set('x'))"),
    ("threading.local attribute", "local.request_id"),
    ("dict lookup", "plain['request_id']"),
]:
    seconds = timeit.timeit(stmt, number=2_000_000, globals=globals())
    print(f"{label:28} {seconds / 2_000_000 * 1e9:6.1f} ns")

Measured: get() 15.7 ns, set() + reset() 140 ns, threading.local attribute 35.4 ns, dict lookup 13.6 ns. A request that sets five variables and reads each twenty times spends under 3 µs on context variables — a small fraction of even a fast handler. Passing values as explicit arguments is cheaper still, but the difference rarely justifies threading a request ID through every function signature.

Verify: you have per-operation timings on your interpreter, and per-request context-variable time is a negligible share of request time.

Nanoseconds per operation 7 horizontal bars comparing dict lookup with the others. Nanoseconds per operation dict lookup 13.6 ns get(), unset, ContextVar default 15.4 ns ContextVar.get() 15.7 ns copy_context(), 1 var set 25.9 ns threading.local attribute 35.4 ns get() in try/except LookupError 120.2 ns set() + reset() 140 ns Python 3.14, timeit over 1-2 million iterations.

2. Do not worry about the number of variables

Each task copies the current context, which sounds as if it should get slower as more variables are set. It does not, because a Context is an immutable hash-array-mapped trie: copying it shares the structure and costs the same regardless of size, and setting a variable creates a new version that shares most nodes with the old one:

async def create_tasks(n_vars: int, n: int = 100_000) -> float:
    for i in range(n_vars):
        contextvars.ContextVar(f"v{i}").set(i)
    async def nop():
        pass
    start = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        for _ in range(n):
            tg.create_task(nop())
    return (time.perf_counter() - start) / n

Measured: creating and running a task took 7.72 µs with no variables set, 7.43 µs with 10, 8.84 µs with 100 and 7.95 µs with 1,000 — within run-to-run noise of each other. copy_context() alone took 36 ns with 10 variables and 40 ns with 1,000; get() of one variable among 1,000 took 55 ns, against 51 ns among 10, when both were measured through ctx.run. The number of variables in context is not a performance concern in practice.

Verify: task creation time in your service does not change when you add context variables.

3. Give variables a default instead of catching LookupError

Reading a variable that has not been set raises LookupError unless a default is supplied. Code that probes for "is this set?" with exceptions pays for the exception every time the answer is no:

tenant = contextvars.ContextVar("tenant")                     # no default

def current_tenant_slow():
    try:
        return tenant.get()
    except LookupError:
        return None

tenant = contextvars.ContextVar("tenant", default=None)       # default at declaration
def current_tenant():
    return tenant.get()

Measured on an unset variable: try/except LookupError 120.2 ns, get(None) 17.0 ns, a declared default 15.4 ns — the exception path costs seven times more. In hot paths that run outside request context, such as logging filters called from background tasks, the exception version is the one that shows up in profiles. Declare a default when "not set" is a normal state, and leave it off only when reading an unset variable is a bug you want to see.

Verify: context variables that are legitimately unset in some code paths declare a default.

4. Watch what context keeps alive

Context variables hold references. Every task created while a value is set copies the context and keeps that reference for its whole life, even after the code that set the value has reset it:

big = contextvars.ContextVar("payload")

async def handle(request):
    token = big.set(await request.read())            # 50 MiB body in context
    try:
        for _ in range(10):
            asyncio.create_task(audit_later())        # each copies the context
        return await process()
    finally:
        big.reset(token)                              # this request's context only

Measured with a 50 MiB object: after the request reset its variable, the object was still alive and resident memory was 50 MiB higher, because the ten long-running background tasks' contexts still referenced it. When the tasks finished, a weak reference showed the object freed and memory back to baseline. Starting the background tasks with create_task(coro, context=contextvars.Context()) — an empty context — kept memory at baseline from the start. Put identifiers in context rather than large objects, and start long-lived tasks without the request's context, as covered in avoiding ContextVar leaks in background tasks.

Verify: resident memory after a request that starts background tasks does not include the request's large objects.

A 50 MiB value in context, 10 background tasks started A grid of 3 rows by 3 columns. A 50 MiB value in context, 10 background tasks started situation object alive? RSS above baseline request done, tasks still running yes +50 MiB tasks finished no (weakref dead) +0 MiB tasks started with context=Context() never referenced +0 MiB Resetting a variable restores the caller's context, not the copies tasks already took.

5. Profile context usage in a real handler

Microbenchmarks set the scale; a profile of a real request shows the share. Run a handler under cProfile or a sampling profiler and look for ContextVar.get, ContextVar.set and Context.run frames:

import cProfile, pstats

async def bench(n=10_000):
    for _ in range(n):
        await handle_request(fake_request())

cProfile.run("asyncio.run(bench())", "ctx.prof")
pstats.Stats("ctx.prof").sort_stats("tottime").print_stats("contextvars|ContextVar")

With the per-operation costs above, a handler would need hundreds of context operations per request before they reached 1% of a 1 ms handler. When context variables do show up, the cause is almost always one of the patterns in steps 3 and 4 — exception-based lookups in a hot path, or large values retained by tasks — rather than context variables themselves. The other place context shows up is in thread hops: asyncio.to_thread copies the context into the thread for you, while loop.run_in_executor does not, as measured in carrying contextvars across threads and executors.

Verify: a profile of your request path shows context-variable operations as a negligible share of time.

Is context costing you anything? A decision on What shows up with 4 outcomes. Is context costing you anything? What shows up? get/set in a profile almost never significant 16 ns / 140 ns many variables set no effect 7.4-8.8 us per task regardless LookupError in a hot path declare a default 120 ns to 15 ns memory after requests large values held by tasks +50 MiB per request The cost is in what you put in context, not in using it.

Verification

Context variable costs are under control when:

  • Per-operation costs are measured and negligible next to request time.
  • Variables that may be unset declare defaults, so lookups never raise in hot paths.
  • Context holds identifiers, not large objects, and long-lived tasks start with an empty or explicit context.
  • A profile of the request path shows no significant time in context operations.

Diagnostic Hook: when memory grows with request volume and the heap shows request bodies or ORM objects outliving their requests, check which tasks hold contexts that reference them — task.get_context() on long-lived tasks, iterated for large values, finds the holders directly.

Pitfalls & edge cases

  • Exception-based lookups. Measured: 120 ns against 15 ns with a default.
  • Large objects in context. Measured: 50 MiB kept alive by background tasks.
  • Assuming reset() frees the value. It restores this context only; copies keep theirs.
  • Optimising away context variables for speed. At 16 ns per get, it is rarely worth it.

Frequently Asked Questions

Are contextvars slow in asyncio?

No: ContextVar.get took 15.7 ns, comparable to a dict lookup and faster than threading.local, and set plus reset took 140 ns in testing.

Does setting many context variables slow down task creation?

No. Task creation took 7.4 to 8.8 µs with 0, 10, 100 or 1,000 variables set, because contexts are immutable and copied by sharing.

Why is my ContextVar lookup slow?

Usually because it raises: try/except LookupError on an unset variable took 120 ns against 15 ns with a declared default.

Can contextvars cause memory leaks?

They can keep objects alive: a 50 MiB value set during a request stayed in memory while 10 background tasks that copied the context were running. Store identifiers, or start such tasks with an empty Context.