Context Propagation and Baggage in Python OpenTelemetry

A trace only stays whole if its context survives every network hop: the trace ID, the parent span ID, and the sampling flag must travel with the request. Context propagation is the serialization layer that moves them, and baggage is the mechanism for carrying your own key-value metadata along the same path. This guide sits within the broader Distributed Tracing and OpenTelemetry in Python guide and builds on a provider configured per OpenTelemetry SDK Setup; it pairs closely with Span Lifecycle and Attributes, which governs the spans whose context you are moving, and it is the foundation for the queue-crossing walkthrough in propagating trace context across Celery tasks.

One hop: inject on the way out, extract on the way in Service A holds an active context made of a span context plus baggage and calls propagate.inject to serialize it into a dictionary of HTTP headers. The carrier travels over the network carrying a traceparent header and a baggage header. Service B calls propagate.extract to parse those headers back into a Context object, attaches it so that it becomes current, starts its span as a child of the remote parent, and detaches the token in a finally block. One hop: inject on the way out, extract on the way in Service A · sender active context span context + baggage propagate.inject(carrier) serialize into the carrier carrier · request headers traceparent: 00-9f0c4bd1…e1-7b2f0c81…a1-01 baggage: tenant.id=acme-corp, region=us-east-1 Service B · receiver propagate.extract(hdrs) returns a Context context.attach(ctx) detach in a finally inject extract extract() returns a context — it does not make it current context.attach(ctx) does that, and its token must be detached in a finally block
The sender injects context into a carrier; the receiver extracts, attaches it as the active context, runs its spans, then detaches.

Key implementation areas covered below:

  • W3C TraceContext and Baggage headers versus the legacy B3 format.
  • The inject and extract lifecycle through the TextMapPropagator interface.
  • contextvars isolation across await, threads, processes, and task queues.
  • Baggage size limits, serialization cost, trust boundaries, and detachment hygiene.

Prerequisites

pip install \
  "opentelemetry-api>=1.30.0,<2.0.0" \
  "opentelemetry-sdk>=1.30.0,<2.0.0"

For B3 compatibility with a Zipkin-era mesh, also install the propagator extension:

pip install "opentelemetry-propagator-b3>=1.30.0,<2.0.0"

The default global propagator is already composite W3C TraceContext plus Baggage, so most services need no extra propagator configuration. You only override it to add or reorder formats. If you prefer configuration over code, the same choice is expressible as an environment variable, which is the form to reach for when the propagation format differs per environment:

export OTEL_PROPAGATORS="tracecontext,baggage"   # the built-in default
export OTEL_SERVICE_NAME="checkout-api"          # identifies the emitting service

Concept and Architecture

Propagation is a header exchange standardized by the W3C TraceContext spec. The traceparent header carries the version, trace ID, parent span ID, and trace flags (including the sampled bit). The tracestate header carries vendor-specific routing data. OpenTelemetry hides the byte format behind the TextMapPropagator interface, which exposes exactly two operations: inject writes the active context into a carrier, and extract reads a carrier back into a Context object.

Anatomy of the propagation headers A traceparent header is four hyphen-delimited fields. The first is the version, always 00 today. The second is a 32-character hexadecimal trace id shared by every span in the trace; an all-zero value is invalid and treated as absent. The third is a 16-character hexadecimal span id identifying the caller's span, which becomes the parent of the next span. The fourth is a two-character flags byte whose low bit is the sampled flag: 01 means sampled, 00 means not sampled. Alongside it, the baggage header carries comma-separated key equals value pairs limited to 4096 characters per entry and 8192 bytes in total, and the optional tracestate header carries vendor pairs that OpenTelemetry preserves untouched. Anatomy of the propagation headers traceparent: version trace id · 32 hex parent span id · 16 hex flags 00 - 4bf92f3577b34da6a3ce929d0e0e4736 - 00f067aa0ba902b7 - 01 spec version one value for the whole trace the caller's span — your parent 01 = sampled all zeroes is invalid, treated as absent 00 = not sampled baggage: tenant.id=acme-corp,region=us-east-1 comma-separated key=value pairs · 4096 chars per entry · 8192 bytes in total tracestate: optional vendor pairs — preserved untouched, even for vendors OpenTelemetry does not know
Four hyphen-delimited fields, and a receiver that rejects any of them silently: wrong field count, a stray non-hex character, or an all-zero trace ID all read as "no context".

The lifecycle is strict and directional. On an outbound call the sender invokes inject(carrier), serializing the active context into a mutable mapping such as a dict of HTTP headers, gRPC metadata, or a message payload. On the inbound side the receiver invokes extract(carrier), which returns a new Context containing the remote span context as a parent reference. Crucially, extract does not make that context active. You must call context.attach() to set it as current, and context.detach() to restore the previous state.

A Context is immutable. Every operation that appears to modify it — baggage.set_baggage, trace.set_span_in_context — returns a new Context rather than mutating the one you passed in. That is why the setter calls in the examples below reassign ctx each time, and why forgetting the return value is such a quiet bug: the code runs, the header is simply missing downstream. attach is the only call that changes what "current" means, and it returns a token that is the sole way back.

The carrier does not have to be a plain dictionary of strings. A propagator reads and writes through a Getter/Setter pair, so a carrier whose values are lists — WSGI environ, multi-value HTTP headers, Kafka record headers as byte tuples — is handled by supplying a custom getter rather than reshaping the data. The SDK ships default_getter and default_setter for dict-like carriers, and framework instrumentation supplies its own for everything else, which is why the same extract call works identically for a Django request and a message consumer.

Baggage rides the same carrier through a separate baggage header. Where a span attribute is local to one span and consumed by the backend, baggage is propagated metadata: a tenant ID, a feature-flag cohort, or a routing directive that every downstream service can read off the active context without re-querying. The distinction matters because misusing one for the other is a common and expensive mistake; Span Lifecycle and Attributes explains when local span storage is the right home for data instead. Baggage is also not automatically copied onto spans — reading a baggage value and calling set_attribute with it is a deliberate, per-service act, which is what keeps a tenant ID out of the index on services that do not need to query by it.

A separate but related concern is correlation with other signals. The sampled flag carried in traceparent is what lets a backend attach an exemplar from a histogram bucket back to the exact trace, a pattern detailed in Python Metrics and Instrumentation. The same active context is what a log formatter reads when adding trace IDs to log records, so a hop that loses context silently breaks log correlation as well as the trace tree.

The wire format is worth understanding because malformed headers fail silently. A traceparent is four hyphen-delimited fields: 00-{32 hex trace id}-{16 hex span id}-{2 hex flags}. The 00 is the version, and the low bit of the flags byte is the sampled flag — 01 means the trace was sampled, 00 means it was not. A receiver that gets a traceparent with the wrong field count, a non-hex character, or an all-zero trace ID treats it as absent and starts a new root rather than raising, so a subtly corrupted header from a buggy intermediary produces broken traces with no error in the logs. tracestate is a separate, optional comma-separated list of vendor key=value pairs that survives alongside traceparent; it is where sampling systems and vendors stash routing hints, and OpenTelemetry preserves it untouched even for vendors it does not understand.

That sampled bit also carries a decision, not a suggestion. Under the default parent-based sampler, a downstream service honours the upstream flag: if the edge sampled the trace out, every service below it drops its spans too, and no amount of local configuration will bring them back. This is what makes the propagation layer and the sampling layer inseparable in practice — a misconfigured edge sampler is indistinguishable from broken propagation when you look only at the leaf service. The decision rules are covered in sampling strategies for distributed tracing.

B3 is the older format from the Zipkin ecosystem, carried either as a single b3 header or as several X-B3-* headers. It encodes the same trace ID, span ID, and sampled bit but is not interchangeable with traceparent on the wire. A service mesh mid-migration will have some hops speaking W3C and some speaking B3, which is the entire reason for a composite propagator: extraction tries each registered format and the first that yields a valid context wins, so a single deployment can accept both while always emitting W3C on the way out.

Step-by-Step Implementation

The ordered calls that move one context across a hop On the sending service, baggage.set_baggage returns a new context, context.attach makes it current and takes the stack to depth one, start_as_current_span adds a span at depth two, and propagate.inject writes traceparent and baggage into the carrier. The carrier crosses an HTTP or queue boundary as plain data. On the receiving service, propagate.extract parses the headers into a context holding a remote parent that is not yet current, context.attach makes it current, start_as_current_span opens a child of the remote parent, and context.detach returns the stack to depth zero so nothing leaks into the next task. The sender detaches its own token last, once the call has returned. The ordered calls that move one context across a hop Service A · sender Service B · receiver ctx = baggage.set_baggage(…) returns a NEW context — reassign it token = context.attach(ctx) depth 1 · this context is now current start_as_current_span('produce') depth 2 · child of the attached ctx propagate.inject(carrier) writes traceparent + baggage context.detach(token) depth 0 · prior context restored HTTP or queue hop — the carrier travels as plain data ctx = propagate.extract(hdrs) a remote parent, not yet current token = context.attach(ctx) depth 1 · inside this handler only start_as_current_span('handle') child of the remote parent span context.detach(token) depth 0 · no leak into the next task attached context (token depth) the active span inside it
Read top to bottom: every attach opens a nested bar that only a matching detach closes, and the hop in the middle carries nothing but the serialized headers.

Step 1 — Confirm or set the global propagator. The SDK ships with a composite W3C propagator. To add B3 for a mixed mesh, register a composite explicitly. Order matters only for which format wins when both are present on extraction.

from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from opentelemetry.propagators.b3 import B3MultiFormat

set_global_textmap(CompositePropagator([
    TraceContextTextMapPropagator(),  # primary
    W3CBaggagePropagator(),           # baggage header
    B3MultiFormat(),                  # legacy fallback
]))

Step 2 — Inject on outbound requests. Set baggage on the context, then start the client span and inject. Instrumentation libraries do this for you, but explicit injection is required for custom transports.

import httpx
from opentelemetry import propagate, trace, baggage, context

tracer = trace.get_tracer("gateway.outbound")


async def call_downstream(url: str, payload: dict) -> dict:
    ctx = baggage.set_baggage("tenant.id", "acme-corp")
    ctx = baggage.set_baggage("region", "us-east-1", context=ctx)
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("outbound_api_call"):
            headers: dict[str, str] = {}
            propagate.inject(headers)  # writes traceparent + baggage
            async with httpx.AsyncClient() as client:
                resp = await client.post(url, json=payload, headers=headers)
                return resp.json()
    finally:
        context.detach(token)

Step 3 — Extract on inbound work and start a child span. The receiver parses headers into a context, attaches it, and starts its span as a child of the remote parent.

from opentelemetry.propagate import extract


async def handle_inbound(request_headers: dict) -> None:
    ctx = extract(request_headers)             # parse traceparent + baggage
    token = context.attach(ctx)
    try:
        tenant = baggage.get_baggage("tenant.id")  # available across this service
        with tracer.start_as_current_span("handle_request") as span:
            span.set_attribute("tenant.id", tenant or "unknown")
    finally:
        context.detach(token)                  # restore previous context

Step 4 — Detach on completion. The finally block is not optional. Because contextvars survive across await, a missing detach leaks the attached context into whatever coroutine the event loop runs next. detach takes the exact token that attach returned and restores the context to its prior state; it is a stack discipline, so detaching tokens out of order — for instance in a nested attach where the inner one is detached after the outer — corrupts the context and logs a warning. Keep each attach/detach pair lexically scoped within one try/finally and never share a token across functions.

Step 5 — Verify the hop before you trust it. Propagation fails silently by design, so assert on it in a test rather than eyeballing a trace viewer. The cheap check is structural: inject into an empty carrier and assert the header exists and parses; the strong check is end-to-end, using an in-memory exporter to confirm the two spans share a trace ID.

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("propagation-test")


def test_context_crosses_the_carrier():
    carrier: dict[str, str] = {}
    with tracer.start_as_current_span("upstream"):
        propagate.inject(carrier)
    assert carrier["traceparent"].startswith("00-")  # version 00, four fields

    token = context.attach(propagate.extract(carrier))
    try:
        with tracer.start_as_current_span("downstream"):
            pass
    finally:
        context.detach(token)

    upstream, downstream = exporter.get_finished_spans()
    assert downstream.context.trace_id == upstream.context.trace_id
    assert downstream.parent.span_id == upstream.context.span_id

Expected Output:

1 passed in 0.06s

Most of this is invisible when you use the official instrumentation. opentelemetry-instrumentation-fastapi, -requests, -httpx, and -grpc inject and extract automatically at the framework's request boundary, so a fully instrumented service propagates context with zero manual inject/extract calls — the setup for those packages is covered in instrumenting Python web frameworks. You only drop to the manual API for transports the instrumentation does not cover: a custom binary protocol, a raw socket, a message format the queue instrumentation does not recognize, or a background task spawned outside the request path. Mixing the two — manually extracting in a handler that the framework instrumentation already extracted for — is a real source of doubled spans, so reach for the manual API only where automatic instrumentation genuinely has no hook.

Baggage carries a subtle security and cost property that span attributes do not: it is automatically forwarded to every downstream service. A tenant ID or feature-flag cohort set once at the edge reaches services three hops away with no further code, which is the feature. The flip side is that anything you put in baggage leaves your trust boundary on every outbound call. Never place secrets, tokens, or PII in baggage, because it will be serialized into plaintext headers and may cross into third-party services or logs you do not control. Treat baggage as a public broadcast channel scoped to the trace, and keep it to small, non-sensitive routing keys.

There is also no automatic eviction. A key set early in a long trace rides every subsequent hop until something explicitly removes it with remove_baggage. In a deep call graph that compounds: each service that adds a key without pruning grows the header monotonically, and you discover the 8 KB ceiling only when a downstream service silently drops the truncated header. The discipline is to set baggage as close to the edge as possible, prune keys the moment they are no longer needed downstream, and treat the baggage header budget as a shared resource the whole request graph spends from. Where a call leaves your perimeter entirely — a payment provider, a partner API — strip baggage explicitly with baggage.clear() on the context you inject from, rather than trusting that nothing sensitive has accumulated upstream of you.

Configuration Reference

Choosing a propagator configuration Start by asking whether any hop in the mesh still speaks B3. If no, stay on the built-in default of tracecontext and baggage, which needs no configuration; a job that must never join a caller's trace sets OTEL_PROPAGATORS to none. If yes, register a composite of tracecontext, baggage and b3multi for the duration of the migration so the service accepts both formats while still emitting W3C; if the mesh uses the single-header b3 variant, use b3 in place of b3multi. The rule underneath: injection runs every registered propagator, while extraction stops at the first one that yields a valid context. Does any hop still speak B3? check the mesh, not your own code no yes Stay on the default OTEL_PROPAGATORS=tracecontext,baggage nothing to configure — this is already on Composite for the migration tracecontext,baggage,b3multi accept both formats; keep emitting W3C A job that must never join a caller's trace OTEL_PROPAGATORS=none Mesh sends the single b3 header? use b3 in place of b3multi inject runs every registered propagator · extract stops at the first one that yields a valid context
Order in the list changes nothing on the way out — every registered format is emitted — and everything on the way in, where the first match wins.
Setting Env var / API Default Production guidance
Active propagators OTEL_PROPAGATORS tracecontext,baggage Comma list; add b3multi only while a B3-only hop remains
Global propagator set_global_textmap() composite W3C Programmatic override; call once, before any span starts
Disable propagation OTEL_PROPAGATORS=none Only for isolated jobs that must never join a caller's trace
Baggage entry size W3C spec limit 4096 chars/entry Keep values under ~64 chars; longer entries risk truncation
Total baggage header W3C spec limit 8192 bytes Budget a handful of small keys; alert on header size in the mesh
Inbound extraction propagate.extract(carrier) Returns a context; does not activate it
Context activation context.attach(ctx) Must be paired with detach(token) in a finally
Baggage read/write baggage.get_baggage / set_baggage set_baggage returns a new context — reassign it
Baggage removal baggage.remove_baggage / clear Prune keys before calls that leave your trust boundary
Non-dict carriers custom Getter / Setter default_getter / default_setter Supply a getter for list-valued or byte-valued headers

The environment variable and the programmatic call configure the same slot, and the last writer wins: set_global_textmap() executed during startup overrides whatever OTEL_PROPAGATORS selected. Pick one mechanism per service and keep it there, because a service where both are used is a service where the effective propagation format depends on import order.

Async and Concurrency Considerations

asyncio is the easy case. contextvars propagate across await, so a span started before an await is still the parent of one started after it within the same coroutine. The hazard is sharing context where you did not intend to, which is exactly why every attach needs a matching detach.

asyncio.create_task deserves its own note because it behaves differently from await. A task copies the context at the moment of creation, not at the moment it first runs, and that copy is independent afterwards. So a task spawned inside a request handler correctly inherits that request's trace context, but any attach performed inside the task is invisible to the parent — and, more usefully, a leaked attach inside the task cannot corrupt the parent's context. Fire-and-forget tasks that outlive the request will therefore keep a parent span that has already ended, which is legal but produces spans whose parent finished before they did; start a fresh root or a linked span for genuinely detached background work.

Where the context follows you, and where it stops Across an await the context flows automatically, so a span started before the await still parents one started after it. asyncio.create_task copies the context at the moment the task is created; the copy is independent afterwards, so an attach inside the task never reaches the parent. A ThreadPoolExecutor worker inherits nothing and starts with a fresh empty context, so you must snapshot with contextvars.copy_context and run the job through ctx.run. A forked or spawned process shares no contextvars at all, so the context has to be demoted to data: inject it into a dict, pass that dict as an argument, and extract it inside the child. Where the context follows you, and where it stops await active request context flows the same coroutine after the await contextvars survive an await, so a span started before it still parents one started after asyncio.create_task active request context copied the task body snapshot at creation the copy is independent — an attach inside the task never reaches the parent ThreadPoolExecutor active request context empty worker thread starts with nothing nothing is inherited; carry it in with copy_context() and run the job via ctx.run fork / spawn active request context gone child process a new interpreter contextvars do not cross a process — inject to a dict, pass it, extract in the child
Only the leftmost boundary is free. The other three each need a deliberate act: a copy you rely on, a snapshot you carry in, or a carrier you pass as data.

Thread pools are the hard case. A ThreadPoolExecutor worker begins with a fresh, empty context, so any span created there is orphaned unless you carry the context in. Snapshot it with contextvars.copy_context() and run the worker through ctx.run(...).

import asyncio, contextvars
from opentelemetry import trace

tracer = trace.get_tracer("worker")


def blocking_work():
    # parent context was copied in via ctx.run, so this span is correctly parented
    with tracer.start_as_current_span("blocking_work") as span:
        span.set_attribute("work.kind", "cpu")


async def dispatch():
    with tracer.start_as_current_span("dispatch"):
        loop = asyncio.get_running_loop()
        ctx = contextvars.copy_context()
        await loop.run_in_executor(None, lambda: ctx.run(blocking_work))


asyncio.run(dispatch())

Process boundaries break the mechanism entirely. contextvars are per-interpreter state, so neither fork nor spawn carries the active context into a multiprocessing worker, and a Gunicorn worker forked at boot has no relationship to any request. The only portable answer is to demote the context to data: inject into a plain dict in the parent, pass that dict as an ordinary argument, and extract-and-attach as the first act of the child. The same technique is what the broader async tracing patterns guide applies to executors and background schedulers.

Task queues need the most care because the context travels as data, not as a thread-local. The producer injects context into the message payload; the consumer extracts it before work begins and detaches after, so trace state never bleeds into the next task. The full worker lifecycle, including retries and acknowledgement, is in propagating trace context across Celery tasks.

There is a semantic choice at the queue boundary that propagation alone does not make for you: should the consumer's span be a child of the producer, or a separate trace linked to it? A child span ties the two into one trace, which reads naturally for a synchronous-feeling request that happens to hop a queue. But for a fan-out where one producer message triggers thousands of consumers hours later, a single trace with thousands of children becomes unwieldy and the timing is misleading. The convention there is a CONSUMER-kind span in its own trace carrying a span link back to the PRODUCER span, which records the causal relationship without forcing everything into one timeline. The choice is yours to make in the worker; propagation just guarantees the producer's span context is available to either parent from or link to.

Connection pools and contextvars interact in one non-obvious way. A pooled object — a database connection, an HTTP client — is created once and reused across many requests, so any span created at pool-construction time is bound to the wrong, long-dead context. Always create the span at borrow-or-use time inside the active request context, never at pool initialization, or every query will appear to descend from the first request that warmed the pool; tracing SQLAlchemy async queries shows the pattern for the engine case, and instrumenting aiohttp client requests for the pooled client session case.

Finally, the same contextvars machinery underpins request-scoped logging, so a service that gets propagation right usually gets log correlation right for free — see using contextvars for request tracing for the logging-side view of the same primitive.

Production Configuration and Trade-offs

Propagator order matters only on extraction, and only when a carrier could plausibly carry more than one format. The composite propagator tries each registered propagator in turn and the first to yield a valid context wins; injection, by contrast, runs every propagator, so a composite of W3C plus B3 emits both header sets on outbound calls. That dual emission is usually what you want during a migration — old services read B3, new ones read W3C — but it doubles the propagation header footprint, so drop the legacy format the moment the last B3-only service is retired.

Header serialization is the measurable cost of propagation, and it scales with what you propagate, not merely that you propagate. The traceparent header is a fixed ~55 bytes; baggage is whatever you put in it. In a high-throughput service the dictionary construction and string formatting on every outbound call is negligible next to the network call it accompanies, but a baggage map that has grown to several kilobytes across many hops is not — it is bytes on every request in the fan-out. Measure header size, not just request count, when you suspect propagation overhead.

One request's propagation header budget, four hops deep At the edge gateway the propagation headers total about 246 bytes. By the second hop, an added feature-flag cohort takes them to roughly 1.1 kilobytes; by the third, pricing experiment ids take them to about 3.2 kilobytes; by the fourth they reach roughly 7.1 kilobytes and are closing on the 8192-byte W3C ceiling. In every bar the traceparent segment is a constant 55 bytes and tracestate is a small optional segment: the growth is entirely baggage that no service pruned. One request's propagation header budget, four hops deep 8192 B — the W3C ceiling hop 1 · edge gateway 246 B one small tenant key hop 2 · orders 1.1 KB + feature-flag cohort hop 3 · pricing 3.2 KB + experiment ids hop 4 · payments nothing was ever pruned — every hop kept its keys 7.1 KB 0 2 KB 4 KB 6 KB 8 KB traceparent · 55 B, fixed tracestate · optional baggage · grows with every hop that adds a key
Measure header size, not request count: the fixed part is a rounding error, and the part that grows is the part nobody pruned.

Inbound trust is the other production decision. Any client can send a traceparent, and by default your edge service will happily adopt it — including its sampled flag and its trace ID. That is correct inside your perimeter and questionable at a public boundary, where a caller can pin every request to one trace ID or force everything to be sampled. The usual posture is to extract normally between internal services and to strip propagation headers at the public ingress, starting a fresh root there and, if you need the caller's identifier at all, recording it as a span attribute rather than adopting it as trace state.

The hardest production case is the partially observable mesh: some services propagate, some strip headers, some are third parties you cannot instrument. The rule is to fail open. A stripped header yields an empty context, the next span becomes a new root, and you keep partial observability rather than crashing the request. Log the gap so the broken hop is visible, and where a service genuinely cannot be instrumented, consider injecting context into a payload field it forwards verbatim, so the trace can be stitched back together on the far side.

Production Code Examples

End-to-end: producer injects, consumer extracts, with verifiable headers

This example shows both halves of one hop and prints the carrier so you can assert on it in a test.

from opentelemetry import trace, propagate, baggage, context
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter

provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("demo")


def producer() -> dict:
    ctx = baggage.set_baggage("tenant.id", "acme-corp")
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("produce"):
            carrier: dict[str, str] = {}
            propagate.inject(carrier)   # serialize context into the message
            return carrier
    finally:
        context.detach(token)


def consumer(carrier: dict) -> str:
    ctx = propagate.extract(carrier)
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("consume") as span:
            tenant = baggage.get_baggage("tenant.id")
            span.set_attribute("tenant.id", tenant or "unknown")
            return tenant or "unknown"
    finally:
        context.detach(token)


carrier = producer()
print("carrier:", carrier)
print("consumed tenant:", consumer(carrier))

Expected Output:

carrier: {'traceparent': '00-9f0c...e1-7b2f...-01', 'baggage': 'tenant.id=acme-corp'}
consumed tenant: acme-corp

The two console-exported spans share one trace_id, and the consume span's parent_id equals the produce span's span_id, confirming the hop reconstructed the tree.

What the worked example produces The produce span is a root: it has a trace id, its own span id, and no parent. Injection writes its context into a carrier holding a traceparent header and a baggage header. Extraction on the far side gives the consume span the same trace id, its own new span id, and a parent span id equal to the produce span's span id. From those two ids alone the backend rebuilds a two-node tree with produce as the root and consume beneath it, and the tenant.id value that rode the baggage header is readable in the consumer. What the worked example produces span: produce trace_id 9f0c4bd1…e1 span_id 7b2f0c81…a1 parent none (root) inject carrier traceparent baggage tenant.id=acme-corp extract span: consume trace_id 9f0c4bd1…e1 span_id c4d8a5f0…30 parent 7b2f0c81…a1 what the backend rebuilds from those two ids produce root — no parent span id consume parent_span_id = produce.span_id both spans carry trace_id 9f0c4bd1…e1 tenant.id rode the baggage header and is read on the far side
Two ids do all the work: the shared trace_id groups the spans, and the parent_span_id turns the pair into a tree.

Graceful fallback when context is missing

A legacy upstream that strips headers yields an empty carrier. extract still succeeds and the next span becomes a new root; log the gap so the broken hop is visible rather than silent.

import logging

logger = logging.getLogger(__name__)


def handle(carrier: dict) -> None:
    ctx = propagate.extract(carrier)
    parent = trace.get_current_span(ctx).get_span_context()
    if not parent.is_valid:
        logger.warning("no inbound trace context; starting new root trace")
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("handle_legacy"):
            pass
    finally:
        context.detach(token)


handle({})  # simulate a stripped-header request

Expected Output:

WARNING:__main__:no inbound trace context; starting new root trace

In production, promote that warning to a counter keyed by the calling service so a newly broken hop shows up as a step change on a dashboard instead of a line buried in logs.

Common Mistakes

Overloading baggage with large payloads. Error signature: truncated baggage headers, dropped context downstream, intermittent broken traces. Root cause: exceeding the 4096-character per-entry or 8192-byte total limits. Remediation: keep baggage to a handful of small routing keys; move bulk data into the request body or a span attribute, and audit header size under load.

Failing to detach context in async loops. Error signature: a trace ID from one request appearing in an unrelated one under concurrency. Root cause: context.attach() without a paired context.detach(), leaving the contextvars token live across await. Remediation: always wrap attached context in try/finally with detach in the finally.

A missing detach: how one request's trace ID lands in the next In the correct case, the handler attaches a context for request A and detaches the token in a finally block, so when the event loop resumes and runs request B the current context has been restored and request B records its own trace. In the leaking case the attach has no matching detach, so the context attached for request A is still current when the loop moves on, and request B's spans are recorded under request A's trace id. A missing detach: how one request's trace ID lands in the next first coroutine next coroutine on the same loop Correct attach paired with detach(token) request A request B current ctx = trace 9f0c…e1 current ctx = trace 3a71…5c detach restores the previous context before the loop moves on Leak attach without detach(token) request A request B current ctx = trace 9f0c…e1 still trace 9f0c…e1 — A's the attached context is still current when the loop resumes
The leak is not a race between the two requests — it is one request's context that nobody ever put back.

Discarding the context returned by set_baggage. Error signature: get_baggage returns None downstream even though the key was set, and no baggage header appears in the carrier. Root cause: Context is immutable, so baggage.set_baggage(...) returns a new context that was never attached or passed on. Remediation: reassign the return value (ctx = baggage.set_baggage(k, v, context=ctx)) and attach that context before injecting.

Using span attributes instead of baggage for cross-service correlation. Error signature: tenant or routing data present in the edge service but absent downstream. Root cause: span attributes terminate at the span; they are not propagated. Remediation: put data that must cross hops in baggage, and reserve attributes for per-operation detail as described in Span Lifecycle and Attributes.

Re-extracting after instrumentation already did. Error signature: doubled or mis-parented spans on instrumented frameworks. Root cause: manually extracting and attaching context in a handler that an instrumentation library already extracted for. Remediation: let the framework instrumentation own extraction; only inject/extract manually on custom transports it does not cover.

Frequently Asked Questions

How does baggage differ from span attributes in OpenTelemetry?

Baggage propagates across service boundaries through headers, while span attributes stay local to a single span. Use baggage for cross-service routing or tenant correlation, and attributes for per-operation detail that the backend indexes.

What is the performance impact of context propagation in Python?

It is minimal when you use native contextvars; the measurable cost is header serialization and the extra bytes on the wire. Keep total baggage well under 8 KB and audit it under high throughput to avoid latency spikes.

How do I handle missing trace context from legacy services?

Extract still returns a valid context, just without a remote parent, so the next span you start becomes a new root. Decide whether to start a fresh sampled trace or drop the request, and log the gap so the broken hop is visible.

Why does my trace ID leak into the wrong request under asyncio?

You attached a context with context.attach but never detached it. The contextvars token persists across await points, so always pair attach with detach in a finally block.

Does baggage survive a process boundary such as fork or multiprocessing?

No. Context lives in contextvars, which are not inherited by a forked or spawned process. Serialize the carrier yourself and pass it as an argument to the child process, then extract and attach it inside the child before any span starts.