Tracing FastAPI Background Tasks
A FastAPI background task runs after the response has been sent, which means the request's span has already ended. Making that work appear as a child of the request produces a waterfall where a child starts after its parent finished. This page covers the correct shape — a linked root span — and the context capture that has to happen while the handler is still running. It builds on async tracing patterns, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
pip install "fastapi>=0.115.0,<1.0.0" \
"uvicorn[standard]>=0.30.0,<1.0.0" \
"opentelemetry-sdk>=1.27.0,<2.0.0" \
"opentelemetry-instrumentation-fastapi>=0.48b0,<1.0.0"
export OTEL_SERVICE_NAME=orders-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
Implementation
Step 1 — Capture the span context inside the handler. By the time the background callable runs, get_current_span() returns whatever is current then, which is usually nothing.
from fastapi import BackgroundTasks, FastAPI
from opentelemetry import trace
from opentelemetry.trace import Link, SpanContext
app = FastAPI()
tracer = trace.get_tracer(__name__)
@app.post("/orders")
async def create_order(payload: dict, background: BackgroundTasks):
order = await repository.create(payload)
ctx = trace.get_current_span().get_span_context() # captured NOW, while it is valid
background.add_task(send_confirmation, order.id, ctx)
return {"id": order.id}
Step 2 — Start a linked root span in the task.
def send_confirmation(order_id: int, request_ctx: SpanContext) -> None:
links = [Link(request_ctx)] if request_ctx.is_valid else []
with tracer.start_as_current_span(
"orders.send_confirmation",
links=links,
kind=trace.SpanKind.INTERNAL,
) as span:
span.set_attribute("order.id", order_id)
span.set_attribute("request.trace_id", format(request_ctx.trace_id, "032x"))
mailer.send(order_id)
The request.trace_id attribute is belt and braces: backends render links inconsistently, and an explicit attribute means a text search for the trace ID finds the background span even when the visual link is not displayed.
Step 3 — Copy the context for work you start yourself. asyncio.create_task copies the context automatically; a thread or an executor does not.
import asyncio
import contextvars
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=4)
async def schedule_blocking_work(order_id: int) -> None:
ctx = contextvars.copy_context() # snapshot, including the span
loop = asyncio.get_running_loop()
await loop.run_in_executor(pool, ctx.run, do_blocking_work, order_id)
ctx.run(fn, *args) executes the function inside the captured context, so the active span at capture time is the active span inside the thread. Without it, the work runs with an empty context and its spans are orphans.
Step 4 — Flush at shutdown. Background work finishing during termination can produce a span that never leaves.
from contextlib import asynccontextmanager
from opentelemetry import trace
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
provider = trace.get_tracer_provider()
provider.force_flush(timeout_millis=5000)
provider.shutdown()
app = FastAPI(lifespan=lifespan)
Step 5 — Know when to stop using background tasks. A BackgroundTask runs in the serving process and dies with it. If losing the work on a rolling restart is unacceptable, it belongs in a queue — and then the context travels in the message, as described in propagating trace context across Celery tasks.
Configuration options
| Concern | Choice | Recommended |
|---|---|---|
| Relationship | child vs link | link, for work after the response |
| Context capture | implicit vs explicit | explicit, inside the handler |
| Thread hand-off | plain submit vs ctx.run |
ctx.run with a copied context |
| Backup correlation | link only vs link + attribute | both — link rendering varies |
| Durability | BackgroundTasks vs a queue |
a queue when the work must happen |
| Shutdown | none vs force_flush |
force_flush in the lifespan |
Verification
curl -s -XPOST localhost:8000/orders -d '{"item":"x"}' -H 'content-type: application/json'
Expected Output (Collector debug exporter):
Span #0
Name : POST /orders
Kind : Server
Trace ID : 4bf92f3577b34da6a3ce929d0e0e4736
Span ID : 00f067aa0ba902b7
End time : 2026-08-02 15:02:11.084
Span #1
Name : orders.send_confirmation
Kind : Internal
Trace ID : 9c1e5a2b7d4f3608a11c2e5b90f34d77
Parent ID :
Start time : 2026-08-02 15:02:11.086
Links:
-> Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
Span ID: 00f067aa0ba902b7
Attributes:
-> request.trace_id: Str(4bf92f3577b34da6a3ce929d0e0e4736)
The shape to check: the background span has its own trace ID and no parent, its start time is after the server span's end time, and the link points at the request. That is an accurate description of what happened; a child span with the same timestamps would not be.
Common mistakes
The background span is an orphan
Error signature: the work is traced but nothing connects it to the request. Root cause: the span context was read inside the background callable, where nothing is current, rather than in the handler. Remediation: capture the context in the handler and pass it explicitly.
A child span starts after its parent ended
Error signature: the waterfall renders the background work outside the request's bar, or the backend flags an inconsistency. Root cause: the background work was modelled as a child of the request span. Remediation: use a new root span with a link.
The last background span is missing on every deploy
Error signature: background spans stop appearing shortly before each restart.
Root cause: the process exited before the batch processor flushed.
Remediation: force_flush in the lifespan shutdown, and give the container a termination grace period long enough for it.
Links versus parents, in general
The link relationship is not specific to background tasks; it is the right shape whenever two units of work are related but neither contains the other. Recognising the pattern saves inventing a different answer each time.
A parent-child relationship claims containment. The child started and finished inside the parent's window, and the parent's duration includes the child's. That is true for an outbound call inside a request handler, and false for anything that outlives the operation that started it.
A link claims association without containment. These two units of work are related, one referenced the other, and the timing relationship between them is whatever the timestamps say. It is the right shape for background tasks, for queue consumers picking up a message, for a batch job processing items that were each queued by a different request, and for a retry that happens long after the original attempt.
The batch case is the one where links are most obviously correct and most often done wrong: a job that processes a thousand messages, each queued by a different request, has a thousand relationships and no parent. Modelling it as a child of any one of them is arbitrary; a span with a thousand links is unwieldy but honest, and in practice a link per batch plus the message IDs as attributes is the workable compromise.
| Relationship | Shape | Example |
|---|---|---|
| Contained, synchronous | parent-child | a query inside a handler |
| Outlives its scheduler | link | a background task, a queue consumer |
| Many-to-one | links | a batch job over messages from many requests |
| Same work, later attempt | link | a retry hours after the original |
| Unrelated | nothing | do not connect them |
Background work that should not be background
A related design question comes up whenever this pattern is used, and it is worth asking explicitly: should this work be a background task at all?
BackgroundTasks runs in the serving process and dies with it, which makes it appropriate for work that is nice to have and inappropriate for work that must happen. The failure is silent — a rolling deploy discards whatever was in flight, and nothing records that it was discarded — which is exactly the shape of problem that surfaces months later as "some confirmation emails never arrive".
The decision usually comes down to one question: if this work is lost, does anyone find out? If yes, it belongs in a queue with the trace context propagated in the message, as described for Celery tasks. If no — a cache warm, a metrics update, an optional notification — an in-process background task is the simpler choice and this page's linking pattern is what makes it observable.
One further habit is worth adopting: give the background span a name that says what the work is rather than that it was backgrounded. A span called orders.send_confirmation is searchable and self-explanatory; one called background_task groups every kind of deferred work in the service into a single unreadable bucket, and the name is the first thing anyone filters on.
Observing what you cannot see
Background work has one further property worth planning for: nobody is waiting for it, so nothing complains when it stops happening. A task that raises produces a span with an error status, which is useful only if somebody looks; a task that is never scheduled produces nothing at all.
Two cheap mitigations cover most of it. A counter incremented at task start and another at successful completion makes the difference between them visible, and a growing gap is the signal that tasks are failing or being discarded. And an alert on the absence of completions over a window catches the case where scheduling stopped entirely — which no error-based signal can, because there is no error.
That is the same argument made for any component that can fail by disappearing, and background tasks are among the most prone to it precisely because their failure inconveniences nobody at the time.
Related
- Async tracing patterns in Python — the parent guide: context across
await, tasks and threads. - Setting up OpenTelemetry in FastAPI — the instrumentation that produces the request span.
- Propagating trace context across Celery tasks — the durable alternative when the work must happen.
- Logging from asyncio tasks without blocking — the same context-copy semantics for log records.
- Recording exceptions and span events — recording a failure in work nobody is waiting for.
Frequently Asked Questions
Why does my background task's span have no parent?
Because FastAPI's BackgroundTasks run after the response has been sent, at which point the request's span has already ended. A child span of an ended span is not an error but it is misleading: the waterfall shows a child that starts after its parent finished. A link is the correct relationship — it says these two are related without claiming one contains the other.
Does contextvars propagate into a BackgroundTask?
Partially, and version-dependently. Starlette runs background tasks in the same task or in a thread pool depending on whether the callable is async, and the contextvars visible there may or may not include what the request set. Do not rely on it: capture what you need — the span context, the request id — inside the handler and pass it explicitly to the background callable.
Should I use BackgroundTasks or a real queue?
BackgroundTasks is right for short work that is nice to have — sending a notification, writing an audit record — and wrong for anything that must happen, because it runs in the same process and dies with it. If losing the work on a rolling restart is unacceptable, it belongs in a queue with the trace context propagated in the message headers.
How do I see a link in a trace view?
Backends render links differently — some show them as a separate section on the span, some as a dotted edge in the waterfall, some not at all. If your backend does not render links usefully, add the trace id of the request as an attribute on the background span as well, so a search still connects them even when the visual does not.