Binding Context Variables in structlog

Request-scoped fields such as request_id, user_id, and trace_id must appear on every log line a request emits, even from deep helper functions that never received a logger argument; structlog solves this with context variables that bind once and merge automatically. This guide is a focused task within the Structlog Architecture and Setup reference, part of the Modern Python Logging Libraries Deep Dive guide. The same async-safety concerns are covered from the standard library angle in context variables and thread safety.

structlog context variable flow per request A request binds request_id and user_id into a context-local store; unrelated call sites read them through the merge_contextvars processor; the store is cleared when the request ends. Request in middleware bind_contextvars request_id, user_id context-local ContextVar store merge_contextvars into event dict clear_contextvars on request exit
How a request binds context variables once and every log call merges them until the context is cleared.

Prerequisites

Install structlog with a pinned range. No other dependency is required for the core API; the web example below assumes you already run an ASGI framework.

pip install "structlog>=24.1.0,<26.0.0"

Set no special environment variables. Context variables are stored in process memory via contextvars.ContextVar and never persist across processes.

Implementation

The mechanism has three moving parts: the merge_contextvars processor in your pipeline, the binding calls (bind_contextvars / unbind_contextvars), and a per-request reset with clear_contextvars. It is worth distinguishing this from logger.bind(). A bound logger threads context through a logger object you must keep passing down the call stack; context variables instead live in a context-local store that any call site reads, so a helper three frames deep that calls structlog.get_logger() fresh still gets the fields. The two compose: bind stable per-logger fields with bind(), and request-scoped fields with bind_contextvars().

1. Add merge_contextvars to the processor chain. This processor copies the current context-local values into the event dictionary before rendering. It must run before the renderer and before any processor that filters on those keys, otherwise the values are not yet in the event dict when that processor runs. Place it early, immediately after the log-level processor. A common subtle bug is putting it after a filtering processor that drops records by request_id — that filter would never see the field.

import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,   # pull request-scoped vars in first
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    # cache_logger_on_first_use speeds up repeated get_logger() calls
    cache_logger_on_first_use=True,
)

2. Bind values once at the request boundary. Call clear_contextvars() first so a reused worker cannot inherit a previous request's fields, then bind_contextvars() with the request-scoped keys. Every logger obtained anywhere in the call stack now emits these fields.

import uuid
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars

log = structlog.get_logger()


def handle_request(user_id: str) -> None:
    clear_contextvars()                       # drop any stale context first
    bind_contextvars(
        request_id=str(uuid.uuid4()),
        user_id=user_id,
    )
    log.info("request received")
    charge_account()                          # helper logs with no logger passed in


def charge_account() -> None:
    # This function never saw request_id, yet it appears on the line.
    structlog.get_logger().info("account charged", amount=42)

Expected Output:

{"event": "request received", "request_id": "5f1c...e9", "user_id": "u-7781", "level": "info", "timestamp": "2026-06-19T10:15:30.123456Z"}
{"event": "account charged", "amount": 42, "request_id": "5f1c...e9", "user_id": "u-7781", "level": "info", "timestamp": "2026-06-19T10:15:30.124001Z"}

3. Remove individual keys, scope, or reset entirely. Use unbind_contextvars("key") to drop a single field mid-request, clear_contextvars() to wipe everything, and bound_contextvars() as a context manager for nested scopes that must restore the prior state on exit. When you need precise nested undo without a with block, capture the tokens bind_contextvars returns and pass them to reset_contextvars — this restores exactly the keys you changed, leaving sibling fields untouched.

from structlog.contextvars import (
    bind_contextvars,
    unbind_contextvars,
    bound_contextvars,
    reset_contextvars,
)

bind_contextvars(tenant_id="acme")
log.info("tenant work started")
unbind_contextvars("tenant_id")               # subsequent lines drop tenant_id

with bound_contextvars(step="reconcile"):     # auto-resets on block exit
    log.info("inside scoped block")           # carries step=reconcile
log.info("outside scoped block")              # step is gone again

tokens = bind_contextvars(attempt=1)          # keep tokens for precise undo
log.info("retrying")
reset_contextvars(**tokens)                    # restore prior value of attempt only

4. Wire it into a request lifecycle. In an ASGI app, bind in middleware and clear in a finally block so a crashed handler still leaves a clean context for the next request on that worker. Binding the same fields once at the edge is the structlog equivalent of using contextvars for request tracing with the standard library.

import uuid
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars

log = structlog.get_logger()


async def context_middleware(request, call_next):
    clear_contextvars()
    bind_contextvars(
        request_id=request.headers.get("x-request-id", str(uuid.uuid4())),
        path=request.url.path,
    )
    try:
        return await call_next(request)
    finally:
        clear_contextvars()                   # guarantee isolation per request

threadlocal vs contextvars

structlog ships two context backends, and the choice is the single most important correctness decision here. The structlog.contextvars API is built on contextvars.ContextVar, whose value is per context rather than per thread. The asyncio event loop copies the current context when it schedules each task, so two coroutines that bind different request_id values never see each other's data even though they run on the same thread and interleave across await points. That property is exactly what makes the contextvars API the only one safe under asyncio.

The legacy structlog.threadlocal API stores state in thread-local storage. Under threads alone it is fine — each thread has its own dictionary — but under asyncio it breaks, because one event-loop thread runs many coroutines, and they all share that single thread-local dictionary. A value bound by request A leaks into request B the moment A awaits and B resumes. Prefer contextvars in all new code; reach for threadlocal only to keep an old synchronous codebase running unchanged, and never mix the two (see the mistakes below).

There is a second, quieter benefit to the copy-on-schedule behavior. Because a child task inherits a snapshot of the parent's context at creation time, fields bound before you spawn a background task with asyncio.create_task flow into that task automatically, while anything the task binds afterward stays local to it. That gives you natural propagation into fan-out work — a request that launches three concurrent sub-tasks sees its request_id on all three — without any explicit hand-off, and without those sub-tasks polluting each other or the parent. A plain thread pool gives you neither property: it neither inherits the submitting context nor isolates the workers, which is why run_in_executor calls that need correlation must re-bind inside the worker.

Concern structlog.contextvars structlog.threadlocal
Thread isolation Yes Yes
asyncio task isolation Yes (context copied per task) No (shared per thread)
Bind helper bind_contextvars() bind_threadlocal()
Merge processor merge_contextvars merge_threadlocal
Reset helper clear_contextvars() clear_threadlocal()
Recommended for new code Yes No

Configuration options

API Purpose Notes
merge_contextvars Processor that injects context-local values into the event dict Must precede the renderer in processors
bind_contextvars(**kw) Set request-scoped keys Returns tokens for fine-grained reset
unbind_contextvars(*keys) Remove specific keys Silent if a key is absent
clear_contextvars() Wipe all bound keys Call at request start and in finally
bound_contextvars(**kw) Context manager scope Restores prior state on exit
reset_contextvars(**tokens) Restore from bind_contextvars return tokens For precise nested undo

Verification

Confirm isolation with a short asyncio test: two concurrent tasks bind different request_id values and neither leaks into the other. Each task runs in its own copied context, so the assertions hold. The await asyncio.sleep between bind and read is the crucial part — it forces the loop to interleave the two tasks, which is precisely where a threadlocal backend would corrupt the result.

import asyncio
import structlog
from structlog.contextvars import bind_contextvars, merge_contextvars, clear_contextvars

structlog.configure(processors=[merge_contextvars, structlog.processors.KeyValueRenderer()])


async def worker(rid: str) -> dict:
    clear_contextvars()
    bind_contextvars(request_id=rid)
    await asyncio.sleep(0.01)                  # yield to the other task
    # capture the merged event dict structlog would render
    return merge_contextvars(None, "info", {"event": "done"})


async def main() -> None:
    a, b = await asyncio.gather(worker("req-A"), worker("req-B"))
    assert a["request_id"] == "req-A", a
    assert b["request_id"] == "req-B", b
    print("isolation verified:", a["request_id"], b["request_id"])


asyncio.run(main())

Expected Output:

isolation verified: req-A req-B

If the two values had been swapped or shared, the binding leaked across tasks, indicating you are on the threadlocal backend or forgot the per-request clear_contextvars().

Common mistakes

merge_contextvars placed after the renderer. If the renderer runs first, it serializes the event dict before the context values are merged, so request_id never appears in the output. Move merge_contextvars to the top of the processors list, before JSONRenderer.

Reusing context across requests on a worker thread. ASGI and threaded servers reuse workers, so a context bound in one request survives into the next unless reset. Always clear_contextvars() at the request boundary, ideally in a finally block, mirroring the discipline described in context variables and thread safety.

Mixing the threadlocal and contextvars APIs. Binding with bind_threadlocal while merging with merge_contextvars silently drops the values, because the two backends use different storage. Pick one backend and use its matching bind, merge, and clear helpers consistently.

Using clear_contextvars to undo a nested scope. Inside a request you might bind a temporary field and then call clear_contextvars() to drop it, but that also wipes request_id and every other request-scoped field, so the rest of the request logs without correlation. Undo a single nested change with reset_contextvars and saved tokens, or wrap it in bound_contextvars, and reserve clear_contextvars for the request boundary only.

Frequently Asked Questions

What is the difference between bind() and bind_contextvars()?

logger.bind() returns a new bound logger instance carrying the merged key-value pairs and only affects that returned logger. bind_contextvars() writes into a context-local dictionary that every logger in the same context reads through the merge_contextvars processor, so the values reach call sites that never touched the bound logger.

Do I need to clear context variables between requests?

Yes. structlog's context variables live in a contextvars.ContextVar, and a reused worker thread or coroutine can inherit stale values. Call clear_contextvars() at the start of each request, or bind_contextvars() inside a try block and reset in finally to guarantee isolation.

Are structlog context variables safe under asyncio?

The contextvars-based API (bind_contextvars, merge_contextvars) is async-safe because each task runs in a copied context. The older threadlocal API is not safe across awaits, since a single thread interleaves many coroutines and they would share one thread-local dictionary.

Does merge_contextvars have to come before the JSON renderer?

Yes. merge_contextvars copies the context-local values into the event dictionary, so it must run before any renderer or filtering processor that reads those keys. Place it early in the processors list, typically right after add_log_level.

How do I undo a nested bind without wiping the whole request context?

Keep the tokens that bind_contextvars returns and pass them to reset_contextvars, or use the bound_contextvars context manager which captures and restores the prior state automatically on block exit. clear_contextvars wipes everything and is too coarse for nested scopes.