Skip to content

Replacing Thread-Local State in Async Code

Thread-local storage was the standard way to carry per-request state in threaded Python — the current user, the request id, a database session, a locale. Flask's original globals, Django's translation state and many logging setups were built on it. In asyncio, every task on the event loop runs on the same thread, so threading.local() is effectively a global shared by every concurrent request. Measured: 1,000 concurrent request handlers that each stored their id in a threading.local, awaited briefly, then read it back — 999 read another request's id. The same handlers using a contextvars.ContextVar had zero mismatches. This guide migrates thread-local state to context variables and deals with the libraries that still use thread-locals internally.

Prerequisites

1. Reproduce the leak

import asyncio
import contextvars
import random
import threading

local = threading.local()
rid_var = contextvars.ContextVar("rid")


async def handler_threadlocal(rid: int, wrong: list) -> None:
    local.rid = rid
    await asyncio.sleep(random.uniform(0, 0.01))     # other requests run here
    if local.rid != rid:
        wrong.append(rid)


async def handler_contextvar(rid: int, wrong: list) -> None:
    rid_var.set(rid)
    await asyncio.sleep(random.uniform(0, 0.01))
    if rid_var.get() != rid:
        wrong.append(rid)

Run 1,000 of each concurrently: the thread-local version reported 999 mismatches (only the last writer saw its own value), the context-variable version none. Every await is a point where another task runs and overwrites the thread-local; each task, by contrast, has its own copy of the context. In production the symptom is log lines carrying the wrong request id, audit records attributed to the wrong user, or — the dangerous one — authorisation checks reading another request's identity.

Verify: run the reproduction; then grep your code for threading.local( and treat each hit in async code as this bug.

1,000 concurrent requests reading back their own id 2 horizontal bars comparing threading.local with the others. 1,000 concurrent requests reading back their own id threading.local 999 wrong ids contextvars.ContextVar 0 wrong ids Each handler stores its id, awaits a random 0-10 ms, then reads it back. On one event loop thread, a thread-local is a global shared by every task.

2. Replace each thread-local with a ContextVar

The translation is mechanical — one context variable per attribute — but do it with the reset discipline that thread-locals never needed:

# before
_state = threading.local()

def set_user(u): _state.user = u
def current_user(): return getattr(_state, "user", None)


# after
_user: contextvars.ContextVar["User | None"] = contextvars.ContextVar("user", default=None)


def current_user() -> "User | None":
    return _user.get()


class RequestContextMiddleware:
    def __init__(self, app) -> None:
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)
        token = _user.set(await authenticate(scope))
        try:
            await self.app(scope, receive, send)
        finally:
            _user.reset(token)

Two behavioural differences matter. A context variable set in a parent is inherited by tasks created afterwards and is invisible to the parent if set in a child task — covered in why ContextVar changes don't flow back from tasks. And a default of a mutable object is shared by everyone, exactly like a module global; keep defaults immutable or None.

Verify: the reproduction harness, pointed at your accessor functions, reports zero mismatches.

3. Carry context into threads you still use

Code that offloads to threads loses context unless it is copied: asyncio.to_thread copies it; run_in_executor and threading.Thread do not, on standard builds. Thread-local state that code in those threads still reads must be re-established there:

import contextvars
import functools


async def render_pdf(order) -> bytes:
    loop = asyncio.get_running_loop()
    ctx = contextvars.copy_context()                      # carry user, request id, locale
    return await loop.run_in_executor(PDF_POOL, functools.partial(ctx.run, _render, order))


def _render(order) -> bytes:
    user = current_user()                                 # works: context came along
    return pdf_engine.render(order, locale=user.locale)

Inside the worker thread, code that reads context variables sees the request's values. Code that reads thread-locals sees whatever the last task on that pool thread left behind — pool threads are reused across requests. If a library you call in threads relies on thread-locals, set them explicitly at the start of each call and clear them in a finally. The full mechanics are in carrying contextvars across threads and executors.

Verify: two concurrent PDF renders for different users each use their own user's locale.

Does per-request state survive this boundary? A grid of 4 rows by 3 columns. Does per-request state survive this boundary? boundary threading.local ContextVar await inside one task overwritten by other tasks preserved create_task shared global copied at creation asyncio.to_thread stale value from the pool thread copied in run_in_executor stale value from the pool thread lost unless ctx.run Context variables travel with the logical request; thread-locals stay with whichever thread ran last.

4. Contain libraries that use thread-locals internally

You can migrate your own code; third-party libraries may still keep state in thread-locals. Typical examples are older ORMs' session registries, some tracing SDKs' "current span", and i18n helpers' active language. Options, from best to worst:

  • Use the library's async or contextvar mode. Many have added one: OpenTelemetry's context is contextvar-based, Django's translation activation is per-context in async code, SQLAlchemy's async sessions are explicit objects.
  • Scope the thread-local to a synchronous section that cannot be interrupted by an await:
from contextlib import contextmanager


@contextmanager
def legacy_locale(lang: str):
    old = getattr(legacy_i18n._local, "lang", None)
    legacy_i18n._local.lang = lang
    try:
        yield
    finally:
        legacy_i18n._local.lang = old


async def handler(request):
    data = await load(request)
    with legacy_locale(request.lang):               # no await inside: safe on one thread
        html = legacy_i18n.render(template, data)
    return html

Without an await between set and use, no other task can run in between, so the shared thread-local is effectively per-request for that span.

  • Run the library in a dedicated thread per request — expensive, and a last resort.

Verify: for every thread-local-using library, document which option you chose and add a concurrency test like step 1.

5. Keep a regression test

Thread-local leaks reappear whenever someone adds a helper that "just stores it on a local". A concurrency test catches it in CI:

import asyncio
import pytest


@pytest.mark.asyncio
async def test_request_state_is_isolated(client):
    async def one(i: int):
        r = await client.get("/whoami", headers={"Authorization": f"Bearer user-{i}"})
        return r.json()["user"]

    results = await asyncio.gather(*(one(i) for i in range(200)))
    assert results == [f"user-{i}" for i in range(200)]

Two hundred interleaved requests through the real middleware stack make any shared-state bug visible. Keep the endpoint doing at least one real await between reading the user and responding, or the test cannot interleave. Testing patterns for concurrency bugs are in reproducing race conditions deterministically.

Verify: inject a threading.local into the user lookup; the test fails.

How to fix this piece of thread-local state A decision on Who owns the thread-local with 3 outcomes. How to fix this piece of thread-local state Who owns the thread-local? my code ContextVar + reset token per task a library with an async mode switch modes e.g. OTel context a library without one await-free section set, use, restore Any thread-local that is read after an await in async code is a cross-request leak.

Verification

Per-request state is safe when:

  • No threading.local is read across an await in async code.
  • Context variables are set and reset in middleware with tokens.
  • Executor and thread calls copy the context when the code they run needs it.
  • A concurrency test with hundreds of interleaved requests passes.

Diagnostic Hook: add the request id from the context variable to every log record, and periodically sample a log line from deep inside a request and check that its id matches the one the access log recorded for that request. A mismatch rate above zero is a leak, and the module that logged the mismatched line is where the thread-local lives.

Pitfalls & edge cases

  • Mutable defaults. ContextVar("x", default={}) is one dict shared by all requests.
  • Setting context in a child task and reading it in the parent. Children's changes do not flow back.
  • Pool threads with leftover thread-local values. Always set and clear explicitly around legacy calls.
  • Assuming frameworks isolate it for you. They isolate their own state, not yours.

Frequently Asked Questions

Does threading.local work with asyncio?

No. All tasks on an event loop share one thread, so a thread-local is shared by every concurrent request on it. In testing, 999 of 1,000 concurrent handlers read another request's value after an await.

What should I use instead of threading.local in async code?

contextvars.ContextVar. Each task runs in its own copy of the context, so values set for one request are invisible to others. Set them in middleware and reset them with the token afterwards.

Do context variables follow work into thread pools?

asyncio.to_thread copies the context into the thread. loop.run_in_executor and threading.Thread do not on standard builds; wrap the function with contextvars.copy_context().run.

What if a library I use relies on thread-locals?

Use its async or context-variable mode if it has one. Otherwise set the thread-local, use the library and restore the old value within a block that contains no await, so no other task can run in between.