Celery logging configuration (tested with a real worker)
By default a Celery worker removes the root logger handlers you configured and logs everything in its own format to its own output. Set app.conf.worker_hijack_root_logger = False to keep your handlers (task loggers still don't reach them), or connect a receiver to celery.signals.setup_logging so Celery leaves logging entirely to you.
If you configure logging in your own code (a file handler, a custom format) and start a Celery worker, your log lines can disappear: by default the worker hijacks the root logger, removes the handlers you added, and sends everything to its own stderr/--logfile output. Below, the same task module is run three ways. Measured with Celery 5.6.3 on Python 3.13.5, a real worker and Redis 7 as the broker.
1. Default: the worker replaces your handlers
$ cat tasks.py
import logging, os
from celery import Celery, signals
from celery.utils.log import get_task_logger
logging.basicConfig(filename="app.log", level=logging.INFO,
format="APP %(levelname)s %(name)s: %(message)s")
app = Celery("tasks", broker="redis://127.0.0.1:8964/0", backend="redis://127.0.0.1:8964/1")
app.conf.worker_hijack_root_logger = os.environ.get("HIJACK", "1") == "1"
if os.environ.get("OWN_LOGGING") == "1":
@signals.setup_logging.connect
def setup_logging(**kwargs):
pass # Celery now leaves logging alone; the basicConfig above stays
log = logging.getLogger("myapp")
task_log = get_task_logger(__name__)
@app.task
def work(x):
log.info("plain logger: working on %s", x)
task_log.info("task logger: working on %s", x)
return x
$ celery -A tasks worker -n w1@example -c 1 --loglevel=INFO > worker.log 2>&1 &
$ python call.py
1
$ grep -a "logger:" worker.log | cut -c1-160
[2026-10-23 11:29:38,391: INFO/ForkPoolWorker-1] plain logger: working on 1
[2026-10-23 11:29:38,392: INFO/ForkPoolWorker-1] tasks.work[718e5707-4875-401f-91a1-d1cddf9f250f]: task logger: working on 1
$ cat app.log
app.log stays empty although logging.basicConfig(filename="app.log") ran when the worker imported tasks.py. Both the plain myapp logger and the task logger ended up in the worker's own output, in Celery's format. get_task_logger() adds the task name and id to each line.
2. worker_hijack_root_logger = False: your handlers stay
$ HIJACK=0 celery -A tasks worker -n w1@example -c 1 --loglevel=INFO > worker.log 2>&1 &
$ python call.py
1
$ grep -a "logger:" worker.log | cut -c1-160
[2026-10-23 11:29:46,772: INFO/ForkPoolWorker-1] tasks.work[adf4ce55-83a2-45a7-b45b-ad846b993097]: task logger: working on 1
$ cat app.log
APP INFO celery.worker.consumer.connection: Connected to redis://127.0.0.1:8964/0
APP INFO celery.worker.consumer.mingle: mingle: searching for neighbors
APP INFO celery.worker.consumer.mingle: mingle: all alone
APP INFO celery.apps.worker: w1@example ready.
APP INFO celery.worker.strategy: Task tasks.work[adf4ce55-83a2-45a7-b45b-ad846b993097] received
APP INFO myapp: plain logger: working on 1
APP INFO celery.app.trace: Task tasks.work[adf4ce55-83a2-45a7-b45b-ad846b993097] succeeded in 0.006449081003665924s: 1
Now your root handler and format are kept, and Celery's own messages go there too. But note the task logger line is only in the worker output, not in app.log: Celery still gives task loggers their own handler and does not propagate them to the root logger. If you need task log lines in your file, use a plain logging.getLogger(...) logger in tasks, or take over completely as in section 3.
3. Connect setup_logging: Celery does not touch logging at all
$ OWN_LOGGING=1 celery -A tasks worker -n w1@example -c 1 --loglevel=INFO > worker.log 2>&1 &
$ python call.py
1
$ grep -a "logger:" worker.log | cut -c1-160
$ cat app.log
APP INFO celery.worker.consumer.connection: Connected to redis://127.0.0.1:8964/0
APP INFO celery.worker.consumer.mingle: mingle: searching for neighbors
APP INFO celery.worker.consumer.mingle: mingle: all alone
APP INFO celery.apps.worker: w1@example ready.
APP INFO celery.worker.strategy: Task tasks.work[5686882b-f2aa-4483-90ec-7ea0c19eab3a] received
APP INFO myapp: plain logger: working on 1
APP INFO tasks: task logger: working on 1
APP INFO celery.app.trace: Task tasks.work[5686882b-f2aa-4483-90ec-7ea0c19eab3a] succeeded in 0.006512746214866638s: 1
When any receiver is connected to celery.signals.setup_logging, Celery skips its logging setup entirely. The worker output has no task lines; everything, including the task logger, goes through the configuration you made (here the basicConfig at import time; in a real project, call logging.config.dictConfig(...) inside the handler). This is the most predictable option if you already have a logging config, for example Django's LOGGING setting.
Related: Celery chain error handling, Celery task timeout and time limits, pytest caplog not capturing logs.
Frequently asked questions
Why are my log messages not written to my file in a Celery worker?
The worker hijacks the root logger by default and removes handlers you added at import time. Set worker_hijack_root_logger = False or connect to the setup_logging signal.
How do I use my own logging config with Celery?
Connect a function to celery.signals.setup_logging and call logging.config.dictConfig in it. When any receiver is connected, Celery skips its own logging setup.
Why do get_task_logger messages not reach my root handler?
Celery attaches its own handler to task loggers and does not propagate them to the root logger, even with worker_hijack_root_logger = False. With the setup_logging signal connected they propagate normally.
Written with AI assistance. Measured on 2026-10-23 with Celery 5.6.3, kombu 5.6.2 and redis-py 8.1.0 on Python 3.13.5, one worker (-c 1), Redis 7 (Docker) as broker and backend. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Celery and Redis are free.