structlog in Celery Workers

Celery configures logging on its own, forks worker processes, and reuses each one for task after task — three behaviours that each break a naive structlog setup in a different way. This page covers the signals that put you back in control, the per-task context binding, and the queue boundary that trace context has to cross. It builds on the structlog processors and pipelines guide, part of the modern Python logging libraries deep dive section.

Four points in a Celery worker's life where logging is decided A Celery worker lifecycle from left to right. At worker boot, Celery would normally configure logging itself, replacing whatever the application set up at import time; connecting the setup_logging signal suppresses that entirely. Next the prefork pool forks child processes, and each child runs worker_process_init, where the structlog configuration is applied so the chain is explicit in the child rather than merely inherited. Then, for every task, task_prerun binds the task name, task id and retry count into contextvars so that every record the task body emits carries them, and task_postrun clears them again. The clearing step is marked as the one most often omitted: because a prefork worker runs the next task in the same process and the same context, stale bindings survive, and records from a task that fails before binding are attributed to its predecessor. worker boot → fork → task → task → task… 1 · worker boot Celery configures logging replacing yours connect setup_logging to stop it 2 · fork the pool worker_process_init configure in the child idempotent, and explicit 3 · task_prerun bind task, task_id, retry every record in the body carries them 4 · task_postrun clear the context the step most often skipped the same process runs the next task what skipping step 4 produces task B logs with task A's task_id — not a missing field, a wrong one, which reads as a real signal in a query and a task that raises before its own binding inherits its predecessor's identity entirely clear rather than overwrite, so nothing survives a task that failed early
Steps 1 and 4 are the ones that surprise people. Celery replaces your configuration by default, and a prefork worker keeps whatever context you leave behind.

Prerequisites

pip install "celery>=5.4.0,<6.0.0" \
            "structlog>=24.1.0,<26.0.0" \
            "opentelemetry-instrumentation-celery>=0.48b0,<1.0.0"
export CELERY_BROKER_URL=redis://localhost:6379/0
export LOG_RENDERER=json

Implementation

Step 1 — Stop Celery configuring logging. Connecting setup_logging — even with a handler that does nothing but call your own setup — makes Celery skip its entire logging configuration.

# worker.py
from celery import Celery
from celery.signals import setup_logging, worker_process_init
from observability.log import configure          # the structlog configure() from the parent guide

app = Celery("tasks")

@setup_logging.connect
def _on_setup_logging(**_kwargs):
    configure()                                  # Celery now leaves logging alone entirely

@worker_process_init.connect
def _on_child_start(**_kwargs):
    configure()                                  # explicit in each forked child

Without the first handler, Celery installs its own root handler and colour formatter during worker startup, and every record the worker emits afterwards has a different shape from the ones your application emits — which usually shows up as "the worker logs are not JSON" long after the deploy.

Step 2 — Bind task context on entry. task_prerun fires in the worker process with the task and its ID in scope.

import structlog
from celery.signals import task_prerun, task_postrun

@task_prerun.connect
def _bind_task_context(task_id=None, task=None, **_kwargs):
    structlog.contextvars.clear_contextvars()    # start from nothing, every time
    structlog.contextvars.bind_contextvars(
        task=task.name,
        task_id=task_id,
        retry=task.request.retries,
    )

@task_postrun.connect
def _clear_task_context(**_kwargs):
    structlog.contextvars.clear_contextvars()

Clearing in both handlers is deliberate. task_postrun covers the normal path; the clear at the top of task_prerun covers the case where a worker was killed mid-task or a signal handler failed, leaving context behind that would otherwise be inherited by the next task.

Step 3 — Carry the trace across the queue. The worker shares no memory with the producer, so trace context has to travel in the message. The OpenTelemetry Celery instrumentation does this automatically; by hand it is two signals.

from celery.signals import before_task_publish
from opentelemetry import trace
from opentelemetry.propagate import inject, extract

@before_task_publish.connect
def _inject_trace(headers=None, **_kwargs):
    if headers is not None:
        inject(headers)                          # writes traceparent into the message headers

@task_prerun.connect
def _bind_trace(task=None, **_kwargs):
    ctx = extract(task.request.get("headers") or {})
    span = trace.get_current_span(ctx)
    sc = span.get_span_context()
    if sc.is_valid:
        structlog.contextvars.bind_contextvars(
            trace_id=format(sc.trace_id, "032x"),
            span_id=format(sc.span_id, "016x"),
        )

The full treatment of that boundary — including what happens to retries and to the apply_async path — is in propagating trace context across Celery tasks.

Step 4 — Use the task logger, or don't. Celery's get_task_logger returns a logger under the celery.task namespace with task metadata attached by Celery's own formatter. With structlog binding that metadata into contextvars, it adds nothing — a plain structlog.get_logger(__name__) is simpler and gives the same fields.

import structlog

log = structlog.get_logger(__name__)

@app.task(bind=True, max_retries=3)
def process_order(self, order_id: int) -> None:
    log.info("processing order", order_id=order_id)
    try:
        charge(order_id)
    except PaymentTimeout as exc:
        log.warning("payment timed out, retrying", order_id=order_id)
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)
What stays constant across a retry, and what does not Three attempts of the same Celery task drawn as a sequence. The trace id, inherited from the web request that queued the task, is identical across all three attempts and identical to the producing request's records, so a single query returns the whole story. The task name is likewise constant. The retry counter increments from zero to one to two, which is what distinguishes the attempts in a log query. The task id behaviour is marked as the subtlety: a retry reuses the same task id by default, so task id alone cannot separate attempts, and a query that groups by it will show three records that look like duplicates. The recommendation is to bind the retry count explicitly on every attempt, because it is the only field that makes the attempts distinguishable. one task, three attempts, one trace field attempt 1 attempt 2 attempt 3 trace_id 4bf92f…a3ce 4bf92f…a3ce 4bf92f…a3ce same as the request task_id 8f1c-…-b207 8f1c-…-b207 8f1c-…-b207 reused by retry retry 0 1 2 the only separator the consequence for queries grouping by task_id shows three records that look like duplicates — bind retry on every attempt and they separate cleanly task.request.retries is available in task_prerun, which is why the binding belongs there rather than in the task body
The task ID is stable across retries, which is convenient for joining and useless for distinguishing. The retry counter is the field that separates the attempts.

Step 5 — Keep the worker's own records in the pipeline. Celery's internal loggers — celery.worker, celery.app.trace, celery.redirected — go through the standard library, so they need the same ProcessorFormatter treatment as any other dependency.

CELERY_LOGGERS = {
    "celery":            {"level": "INFO", "propagate": True},
    "celery.app.trace":  {"level": "INFO", "propagate": True},   # task succeeded/failed lines
    "celery.redirected": {"level": "WARNING", "propagate": True},  # stdout/stderr capture
}

celery.app.trace is the one worth keeping at INFO: it emits the "Task succeeded in 0.42s" and "Task raised unexpected" records, which are the worker's equivalent of an access log.

What crosses the broker, and what does not A web process and a worker process separated by the broker. In the web process, a request carries a request id and a trace id in contextvars, plus a logger configuration and a set of bound values. When the task is published, only what is written into the message headers crosses the broker: the traceparent header, and any application fields explicitly added to the task arguments. Everything else — the contextvars, the bound loggers, the configuration — is process-local memory and does not travel. In the worker process, the task_prerun signal extracts the traceparent back out of the headers and rebinds it, which is what makes the worker's records join the request's trace. The diagram marks the common mistake: assuming that because both processes run the same codebase and the same configure function, context set in one is visible in the other. two processes, no shared memory — only the message crosses web process request_id in contextvars trace_id in the active span bound loggers structlog configuration none of this is shared the message headers: traceparent args: order_id=8812 worker process task_prerun extracts traceparent binds trace_id, task, task_id its own configuration its own contextvars records now join the trace the assumption that breaks this "both processes run the same code and call the same configure(), so the context is there" — it is not; it is memory, and memory does not cross a broker anything you want on the worker side has to be written into the message, explicitly or by an instrumentation that does it for you
Same codebase, same configuration function, two address spaces. Only what you write into the message survives the trip.

Configuration options

Option Where Default Recommended
setup_logging signal worker unconnected connect it — suppresses Celery's config
worker_process_init child unconnected apply configure()
task_prerun per task clear, then bind task fields
task_postrun per task clear
worker_hijack_root_logger Celery setting True irrelevant once setup_logging is connected
celery.app.trace level logger INFO INFO — the task outcome records
Trace propagation headers none inject/extract, or the OTel instrumentation

Verification

celery -A worker worker --loglevel=INFO --concurrency=2

Queue a task from a traced web request and check that both sides share a trace ID and that consecutive tasks do not share a task ID.

Expected Output:

{"event": "queueing order", "logger": "api.orders", "level": "info", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "order_id": 8812}
{"event": "processing order", "logger": "tasks.orders", "level": "info", "task": "tasks.process_order", "task_id": "8f1c...b207", "retry": 0, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "order_id": 8812}
{"event": "payment timed out, retrying", "logger": "tasks.orders", "level": "warning", "task_id": "8f1c...b207", "retry": 0, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"}
{"event": "processing order", "logger": "tasks.orders", "level": "info", "task_id": "8f1c...b207", "retry": 1, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"}

Two properties to check specifically: the trace_id on the first line — emitted by the web process — matches every worker line, and retry increments while task_id does not. Then queue two different tasks in sequence and confirm the second one's records carry its own task_id, which is the test for the clearing step.

Common mistakes

Worker logs are not JSON

Error signature: application records are JSON; Celery's own startup and task records are coloured text. Root cause: Celery configured logging during worker startup and replaced the root handler. Remediation: connect the setup_logging signal, which makes Celery skip its configuration entirely.

Records carry the previous task's ID

Error signature: a task's records are attributed to a task that finished a second earlier. Root cause: contextvars bound in task_prerun and never cleared, in a prefork worker that reuses the process. Remediation: clear in task_postrun and again at the top of task_prerun.

The trace stops at the queue

Error signature: the producing request has a trace; the task has a different one, or none. Root cause: trace context was never written into the message headers, so the worker had nothing to extract. Remediation: inject at before_task_publish and extract at task_prerun, or install the OpenTelemetry Celery instrumentation, which does both.

What a worker's log should contain

A web service's log is organised around requests; a worker's is organised around tasks, and the useful field set differs accordingly. Six fields cover most of what anyone asks of a worker log.

The task name and the task ID, bound in task_prerun, which together identify the unit of work. The retry count, because it is the only field that distinguishes attempts of the same task. The queue, when the worker consumes more than one, since queue depth problems are per-queue. The trace ID, carried from whatever queued the task. And the outcome with a duration, emitted once when the task finishes, which is the record that answers "how long do these take" without needing every intermediate step.

from celery.signals import task_postrun
import time

@task_prerun.connect
def _start(task_id=None, task=None, **_kw):
    task.request.__dict__["_started"] = time.perf_counter()

@task_postrun.connect
def _finish(task_id=None, task=None, state=None, **_kw):
    started = task.request.__dict__.get("_started")
    structlog.get_logger("tasks").info(
        "task finished",
        state=state,
        duration_s=round(time.perf_counter() - started, 3) if started else None,
    )
    structlog.contextvars.clear_contextvars()

That single record per task is worth more than a dozen progress records, because it carries the outcome and the duration as fields you can aggregate rather than as text you can read.

Field Bound in Answers
task task_prerun which kind of work
task_id task_prerun which unit of work, across retries
retry task_prerun which attempt
queue task_prerun which backlog it came from
trace_id extracted from headers what queued it
state, duration_s task_postrun how it went, and how long

The failure modes worth logging deliberately

Three worker-specific failures produce no useful record unless you arrange one, and all three are common enough to be worth the signal handlers.

A task that raises. Celery logs this through celery.app.trace at ERROR with the traceback, which is adequate — provided that logger propagates to your handlers, which is the configuration point covered above. Connect task_failure if you want the failure in your own event vocabulary rather than Celery's.

A task that is revoked or times out. These do not raise inside the task; the worker terminates it. task_revoked carries the reason, and a soft time limit raises SoftTimeLimitExceeded inside the task, which is catchable and is the better of the two mechanisms precisely because it is.

A worker that stops consuming. Nothing raises. The signal is absence — no task records from that worker — which means the detection belongs in metrics rather than logs: a heartbeat gauge per worker, alerting on staleness. That is the same argument made for any component that can fail by disappearing.

Prefork, threads and the context

The default prefork pool gives each task a whole process, so contextvars behave straightforwardly as long as they are cleared between tasks. Two other pool types change that.

With the threads pool, several tasks run concurrently in one process on different threads. Contextvars are per-thread-of-execution, so bindings do not leak between concurrent tasks — but a module-level logger.bind() result shared across them does, which is an argument for using bind_contextvars rather than a shared bound logger.

With gevent or eventlet, the picture is more complicated: greenlets have their own context semantics, and monkey-patching happens before your configuration runs. If you use those pools, verify the isolation with a test that runs two tasks concurrently and asserts each one's records carry its own task_id — the same test is worth having under any pool, and under those two it is worth running before trusting anything else on this page.

Frequently Asked Questions

Why does Celery override my logging configuration?

By default Celery configures logging itself during worker startup — it sets up the root logger, its own task logger, and a colour formatter. Connecting the setup_logging signal, even with an empty handler, tells Celery to skip all of that and leave the configuration to you. Without it, whatever you configured at import time is replaced when the worker boots.

Do I need to configure structlog again in each forked child?

Under the default prefork pool the children inherit the parent's memory, so a configuration applied before the fork is usually present. It is still worth applying it from worker_process_init: it is idempotent, it makes the behaviour explicit, and it is the only thing that works when the pool is started with the spawn method or when the configuration depends on per-child state such as the process index.

Why do log records show the wrong task id?

Because contextvars were bound in task_prerun and never cleared. A prefork worker runs one task after another in the same process and the same context, so the previous task's identity is still bound when the next one starts. Clear in task_postrun, and clear rather than rebind, so a task that fails before binding does not inherit its predecessor's id.

How do I keep the trace id from the request that queued the task?

Inject the trace context into the message headers when publishing and extract it in the worker. The OpenTelemetry Celery instrumentation does this for you; done by hand, it is a before_task_publish signal that writes the traceparent header and a task_prerun that reads it back. Either way the join key has to travel in the message, because the worker shares no memory with the producer.