Loguru in Multiprocessing Workers

Loguru's sinks are thread-safe out of the box, which covers most single-process services. Once several processes write to the same sink — a pool of workers appending to one file, a prefork server's workers sharing a log path — the guarantees change, and the enqueue option and the process start method decide whether records arrive intact, arrive twice, or do not arrive at all. This page covers configuring sinks once, sharing them correctly under both fork and spawn, and draining them at shutdown. It is a task article under Loguru configuration and sinks, part of the modern Python logging libraries deep dive section, and it pairs with async logging with Loguru enqueue.

Four writers or one Four worker processes need to log to the same file. In the first arrangement, each worker adds its own file sink for that path. Every process opens the file independently, writes without coordination and checks the rotation size independently, so lines from different workers interleave mid-record when a line exceeds the write atomicity limit, and when the size threshold is reached each worker rotates separately, producing several rotated files and records written into files that have just been renamed away. In the second arrangement, the parent adds one file sink with enqueue enabled before starting the workers. Workers inherit a handle to a multiprocessing queue; they put records on it, and a single writer thread in the parent takes them off and writes them to the file. Lines never interleave, rotation happens once, and the file is consistent. The note records that the first arrangement appears to work at low volume and fails as soon as records get long or traffic gets high. four workers, one log file a sink per worker w1 sink w2 sink w3 sink w4 sink interleaved lines · four rotations · records lost one enqueued sink in the parent w1 w2 w3 w4 multiprocessing queue → one writer thread one file · whole lines · one rotation the per-worker version works at low volume it fails as soon as a record exceeds the write atomicity limit or the file reaches its rotation size which is to say, in production, eventually
Several processes appending to one file need one writer. The enqueue option provides it, but only if the sink is added once, where every worker can reach it.

Prerequisites

pip install "loguru>=0.7.0,<0.8.0"

Implementation

Step 1 — Enable enqueue on sinks shared between processes. With enqueue=True, calls to the logger put records on a multiprocessing-safe queue, and a background thread in the process that added the sink takes them off and writes them. Every record reaches the sink through one writer, so lines never interleave and file rotation happens exactly once.

from loguru import logger

logger.remove()                                   # no default stderr sink
logger.add(
    "/var/log/app/worker.jsonl",
    serialize=True,                               # one JSON object per line
    enqueue=True,                                 # 1. one writer for every process
    rotation="64 MB",
    retention=5,
    level="INFO",
)

Step 2 — Configure in the parent, before starting workers. The sink with its queue and writer thread is created once, in the main process. Workers created afterwards by forking inherit the logger object and its reference to the queue, so their records flow to the parent's writer. Workers must not add their own sinks for the same destination, or the benefit is lost.

import multiprocessing as mp

def work(item: int) -> int:
    logger.info("processing item", item=item)     # goes through the parent's queue
    return item * 2

if __name__ == "__main__":
    ctx = mp.get_context("fork")
    with ctx.Pool(4) as pool:
        results = pool.map(work, range(100))
    logger.complete()                             # drain before the parent exits

Step 3 — Pass the logger explicitly under spawn. The spawn start method creates each child as a fresh interpreter that re-imports the main module and inherits nothing. Sinks configured in the parent do not exist there. Passing the configured logger to each worker through the pool's initialiser, and replacing the child's own logger with it, restores the path to the parent's queue.

import multiprocessing as mp
from loguru import logger

def init_worker(parent_logger):
    global logger
    logger = parent_logger                        # 2. the parent's queue-backed logger

def work(item: int) -> int:
    logger.info("processing item", item=item)
    return item * 2

if __name__ == "__main__":
    logger.remove()
    logger.add("/var/log/app/worker.jsonl", serialize=True, enqueue=True)
    ctx = mp.get_context("spawn")
    with ctx.Pool(4, initializer=init_worker, initargs=(logger,)) as pool:
        pool.map(work, range(100))
    logger.complete()

Expected Output: one file, whole lines, each carrying the worker's process identifier.

{"text": "processing item\n", "record": {"message": "processing item", "extra": {"item": 41}, "process": {"id": 18234, "name": "SpawnPoolWorker-2"}, "level": {"name": "INFO"}, "time": {"repr": "2026-09-18 14:02:11.408000+00:00"}}}

Step 4 — Drain the queue before exit. Records sit in the queue until the writer thread takes them, and the writer runs at its own pace rather than the workers'. If the parent exits before that happens, they are lost. logger.complete() blocks until everything enqueued so far has been written, and calling it at shutdown — after the pool has finished — makes sure the final records reach the file.

Step 5 — Under a prefork server, prefer standard output. A web server's workers are separate processes too, but the simplest correct arrangement there is not a shared file at all: each worker writes to standard output, the platform captures each process's stream, and nothing is shared. Loguru's standard error sink is replaced with a standard output sink in each worker, with enqueue optional — it adds a background writer, which keeps a slow pipe off request threads, as described in async logging with Loguru enqueue.

Fork inherits, spawn starts fresh Two process start methods are compared. Under fork, the child is a copy of the parent at the moment of forking: it has the same logger object, the same configured sinks and the same reference to the enqueue queue, so records logged in the child reach the parent's writer thread with no further work. Under spawn, the child is a new interpreter that imports the main module from scratch: the logger it sees is Loguru's default, with only the standard error sink, and the parent's file sink and queue do not exist. Records from the child go to standard error, if anywhere, and never reach the file. Passing the parent's logger through the pool initialiser gives the child the queue-backed logger, after which it behaves as under fork. The note records that spawn is the default on macOS and Windows and is used by some Linux configurations, which is why code that works on a developer's Linux machine can log nothing elsewhere. what the child process gets fork a copy of the parent same logger, same sinks, same queue records reach the parent's writer no extra work needed spawn a fresh interpreter Loguru's default: stderr only the parent's sink does not exist fix: pass the logger via initializer spawn is the default on macOS and Windows, and in some Linux setups code that logs correctly on one developer's machine can log nothing on another's
Under fork the queue comes along for free. Under spawn nothing does, and the logger has to be handed to each child explicitly.

Why the standard library faces the same problem

None of this is peculiar to Loguru. The standard library's logging has exactly the same issue with several processes appending to one file, and the same solution: a queue feeding a single writer, with QueueHandler in the workers and a QueueListener in the parent, as described in thread-safe logging in multiprocessing.

What Loguru adds is packaging. The single enqueue=True flag sets up the queue, the writer thread and the handoff, where the standard library needs three objects wired together by hand. The price is that the mechanism is less visible: developers who have not read about it may not realise that the writer thread lives in the process that called add, which is why adding the sink in each worker — rather than once in the parent — quietly defeats it.

The fork-versus-spawn distinction applies to both libraries equally. Anything relying on inheriting configured state from the parent works under fork and fails under spawn. Code intended to run on more than one platform, or to survive a future change in the default start method, should pass whatever the workers need explicitly rather than relying on inheritance.

A further option sidesteps the question for pools that do not need a shared file: each worker logs to standard output with its process identifier in every record, and whatever collects standard output keeps the streams separate. This is how prefork web servers are usually run, and it works for task pools just as well when the output is being collected by a platform rather than read from a local file.

Context that identifies the worker

Once records from many processes land in one file, telling them apart becomes the next problem, and Loguru's record already carries most of what is needed.

Every record includes the process identifier and name, which is enough to separate workers within one run. Across runs it is not: process identifiers are reused, so a pid alone cannot distinguish today's worker 18234 from yesterday's. Binding a run identifier once in the parent — logger = logger.bind(run_id=...) before the pool starts — gives every record from every worker in that run a common key, and the process identifier then distinguishes workers within it.

For task pools, binding the task's own identifier inside the worker function, with logger.contextualize(task_id=...) around the work, attaches it to every record the task produces, including records from library code called within it. Because contextualize uses context variables, it is scoped correctly even when a worker runs several tasks in sequence, and it never leaks from one task to the next.

These values travel through the enqueue queue with the record, which means they must be picklable. Plain strings and integers always are; bound objects — a request, a database session, a client — may not be, and fail at the moment of logging rather than at configuration time. Binding identifiers rather than objects avoids the problem entirely and is better practice regardless, for the reasons set out in logging personal data safely.

Which Loguru setup for which process model A table of process models and the Loguru configuration that works for each. With a fork start method and a file sink, add the sink with enqueue=True in the parent before forking, so children inherit the queue and one writer owns the file. With the spawn start method, used by default on macOS and Windows, children start fresh; pass the logger to the child and have it call logger.reinstall or configure it again. With Gunicorn prefork workers writing to stdout, configure in post_fork; stdout needs no single writer. With a Celery pool, configure in the worker_process_init signal. The note says the file sink is the case that needs one writer; stdout lets the container runtime merge streams. process model configuration fork + file sink enqueue=True in the parent, before fork spawn (macOS, Windows) pass logger; reinstall() in each child Gunicorn + stdout configure in post_fork Celery prefork pool configure in worker_process_init only a shared file needs a single writer stdout lets the container runtime merge the streams
The start method and the sink decide the setup. A shared file needs one writer; stdout does not.

Configuration options

Setting Value Why
enqueue True on shared sinks one writer across processes
Where add is called the parent, once the writer thread lives there
Start method fork inherits sinks works without extra steps
Start method spawn pass logger via initializer children start with defaults
logger.complete() at shutdown drains the queue
serialize True one JSON object per line
Prefork web servers stdout per worker nothing shared, nothing to coordinate

Verification

Run a pool that logs many long records and check the file for interleaved or duplicated lines and a single rotation sequence.

import json
bad, total = 0, 0
for line in open("/var/log/app/worker.jsonl"):
    total += 1
    try:
        json.loads(line)
    except json.JSONDecodeError:
        bad += 1
print(f"{total} lines, {bad} malformed")

Expected Output: every line whole.

400000 lines, 0 malformed

Malformed lines mean more than one process is writing to the file directly — a worker added its own sink, or the sink was added without enqueue.

Common mistakes

A file sink added in every worker. Error signature: interleaved lines and several rotated files. Root cause: each process writing and rotating independently. Remediation: add once in the parent with enqueue=True.

Relying on inheritance under spawn. Error signature: workers that log nothing to the file. Root cause: spawned children start with Loguru's defaults. Remediation: pass the logger through the pool initialiser.

No complete() at shutdown. Error signature: the last records before exit missing. Root cause: the queue abandoned with records in it. Remediation: call logger.complete() after the pool finishes.

A shared file under a web server. Error signature: file contention and rotation problems in production only. Root cause: server workers sharing a path. Remediation: standard output per worker.

enqueue with unpicklable extras. Error signature: an error when logging objects bound with bind. Root cause: records crossing a process boundary must be picklable. Remediation: bind plain values — strings, numbers — rather than objects.

Frequently Asked Questions

Is Loguru process-safe?

Its sinks are thread-safe by default. For several processes writing to the same sink — particularly the same file — enqueue must be enabled, which routes records through a multiprocessing queue to one writer so lines never interleave and rotation happens once.

Why do my worker processes log nothing?

Usually the start method. With spawn, which is the default on macOS and Windows and increasingly elsewhere, child processes start fresh and do not inherit the parent's configured sinks. The logger has to be passed to them, typically through the pool initialiser.

Why is my log file corrupted or rotated several times?

Several processes each added their own file sink for the same path. Each rotates independently and writes without coordination, producing interleaved lines and multiple rotated files. Configure the sink once, in the parent, with enqueue enabled.

Do I need enqueue under Gunicorn?

Not if each worker writes to standard output, which is the recommended arrangement: the platform captures each process's output separately and nothing is shared. Enqueue matters when workers share a file sink.