Async and Non-Blocking Logging with Loguru enqueue

A logging call that writes to a slow file, a network socket, or a sink under lock contention stalls the thread that made it, and in an asyncio service that stall lands on the event loop where every other request is waiting. This page is for backend engineers and SREs running Loguru in async or multiprocess Python services who need logging calls to return in constant time and still lose nothing at shutdown. It sits within the Loguru configuration and sinks reference, part of the Modern Python Logging Libraries Deep Dive guide, and it covers exactly one argument — enqueue=True — and everything that argument implies about pickling, worker failure, and drain ordering.

Loguru enqueue: many producers, one queue, one writer The main process, a forked child process and a worker thread each pickle a record and put it on a shared multiprocessing SimpleQueue. One daemon worker thread loops on queue.get and writes every record to app.log, so writes are serialized and no producer blocks on the sink. logger.complete() returns only once the queue is empty. main process logger.info(...) child process same queue (fork) worker thread logger.info(...) pickle + put multiprocessing SimpleQueue buffered records logger.complete() blocks until this is empty background worker daemon thread, one per sink queue.get() → write app.log one writer, whole lines
Records from every process and thread funnel through one queue to a single writer, making logging both non-blocking and multiprocess-safe.

Prerequisites

Install Loguru with a pinned range. The queue machinery uses only the standard library, so no extra dependency is needed.

pip install "loguru>=0.7.0,<0.8.0"

No environment variables are required. The queue lives in process memory, so nothing has to be provisioned or reachable over the network; on fork-based multiprocessing the child inherits the same writer, and on spawn you re-add the sink in each process. If you are still deciding whether Loguru is the right base for the service at all, the trade-offs are laid out in choosing a logging library for FastAPI.

Implementation

enqueue=True is a single argument on logger.add, but using it correctly means understanding the worker/queue model, what may cross the queue, exception forwarding, and shutdown ordering.

1. Understand the worker/queue model. When you add a sink with enqueue=True, Loguru does three things: it creates one multiprocessing.SimpleQueue per sink, spawns a daemon thread that loops on queue.get(), and replaces the synchronous write path with a queue.put(record). The hot path on the caller is therefore a pickle of the record plus a queue put — bounded, predictable work — while the unbounded part (formatting, encoding, the actual write/flush, lock acquisition) happens on the worker. Because there is exactly one worker per sink draining one queue, writes are serialized: two threads can never interleave bytes into the same file. The queue is unbounded, so back-pressure is your responsibility (see the slow-sink mistake below).

One logging call, with and without enqueue Without enqueue the calling thread stays busy for the whole of format, write and flush before it returns. With enqueue the caller only pays for a pickle and a queue put and returns almost immediately, while a separate worker-thread track performs the identical format, write and flush later, drained by logger.complete(). enqueue=False · caller thread pays for the sink format write() flush caller returns after the flush enqueue=True · caller thread pays for a put pickle put caller returns · thread free enqueue=True · worker thread absorbs the same I/O format write() flush drained on logger.complete() elapsed time
The work is identical in both cases; enqueue only changes which thread waits for it, and the caller's cost collapses to a pickle and a queue put.

2. Keep enqueued records picklable. Anything you attach to a record through logger.bind(...) or pass as a keyword must be picklable, because the record crosses the queue boundary before it is formatted. A bound database connection, an open socket, or a lambda raises PicklingError at put time — at the call site, not on the worker. Keep enqueued records to plain data (strings, numbers, dicts) and resolve rich objects to their string form where you log them. This is rarely a real constraint in practice, because the fields worth binding are correlation values such as request and trace identifiers, exactly the pattern described in adding trace IDs to log records.

3. Add a sink with enqueue=True. Every logger.info(...) call serializes the record, pushes it onto the queue, and returns immediately; the worker formats and writes it.

from loguru import logger
import sys

logger.remove()                               # drop the default stderr sink
# enqueue=True offloads writes to a background worker thread
logger.add(sys.stderr, enqueue=True, level="INFO")
# A file sink benefits the most because disk writes block the caller
logger.add("app.log", enqueue=True, serialize=True, level="DEBUG")

logger.info("non-blocking write", request_id="req-9931")

Expected Output:

2026-06-19 10:15:30.123 | INFO     | __main__:<module>:9 - non-blocking write

4. Forward worker exceptions. A failure inside the background worker is invisible to the caller — the exception is raised on the worker thread, not at the call site. Keep catch=True (the default) so the worker prints a traceback to stderr instead of dying silently and freezing the queue. A dead worker is the worst failure mode: puts keep succeeding, the queue grows without bound, and nothing is ever written.

# catch=True (default) reports sink errors from the worker thread
logger.add("app.log", enqueue=True, catch=True)

5. Drain the queue on shutdown. Because records are buffered, a process that exits abruptly loses anything still queued. logger.complete() blocks until the worker has flushed every pending record across all enqueued sinks; logger.remove() drains, stops the worker thread, and joins it for that sink, closing the file. The worker is a daemon thread, so it will not keep the interpreter alive on your behalf — an explicit drain is mandatory, not a nicety. A clean shutdown calls complete() to flush and then remove() to tear the workers down, in that order.

from loguru import logger

logger.add("app.log", enqueue=True)
logger.info("important event")

logger.complete()                             # wait for the queue to drain
logger.remove()                               # join worker, flush remaining records

6. Use it from asyncio. A coroutine calling logger.info(...) with an enqueued sink never blocks the event loop, because the write happens on the worker thread. This is the main reason to reach for enqueue in an async service: even a fast local file write involves a syscall that, without enqueue, runs synchronously on the loop thread and adds tail latency to every awaiting request. With enqueue, the loop thread only pays for a queue put. The nuance is shutdown: logger.complete() is awaitable, and from a coroutine you await logger.complete(), which yields control instead of stalling the loop until the queue drains. Put that await in your application's shutdown hook — an ASGI lifespan shutdown, for instance — so in-flight records are written before the loop closes. Non-blocking dispatch like this is the same goal pursued by the standard library's non-blocking logging with QueueHandler, and the same discipline that keeps blocking calls off the loop in async tracing patterns.

import asyncio
from loguru import logger

logger.remove()
logger.add("app.log", enqueue=True, serialize=True)


async def handler(n: int) -> None:
    logger.info("handled", n=n)               # returns instantly, no loop stall


async def main() -> None:
    await asyncio.gather(*(handler(i) for i in range(1000)))
    await logger.complete()                    # await the flush, then exit cleanly


asyncio.run(main())

Expected Output:

{"text": "...handled\n", "record": {"extra": {"n": 999}, "level": {"name": "INFO", "no": 20}, "message": "handled"}}

How enqueue makes logging multiprocess-safe

When multiple processes write to the same file directly, their writes interleave and produce corrupt or partial lines. With enqueue=True, the queue is a multiprocessing.SimpleQueue: child processes that inherit the logger push records onto the same queue, and exactly one worker in the parent dequeues and writes. Serialization to one writer is what makes the file safe, and it is the same single-writer guarantee behind thread-safe logging in multiprocessing with the standard library.

The start method decides whether inheritance happens at all. Under fork (the default on Linux), the child is a copy-on-write clone of the parent, so it inherits the live queue object while the worker keeps running in the parent — children only ever put, the parent's worker writes. Under spawn (the default on macOS and Windows, and increasingly recommended on Linux to avoid fork-after-thread hazards), the child is a brand-new interpreter that re-imports your module; it does not inherit the parent's in-memory queue or worker thread, so a sink added only at parent import time does not exist in the child and its records vanish. The fix under spawn is to re-add the enqueued sink inside each child's startup, guarded so it runs once per process.

fork versus spawn with an enqueued Loguru sink Left panel: fork clones the parent, so the child inherits the live queue object and the parent's worker keeps writing — children only put and lines never interleave. Right panel: spawn starts a fresh interpreter that re-imports the module without the parent's queue or worker, so a sink added only in the parent does not exist in the child and its records are lost until logger.add with enqueue=True is called inside each child entry point. fork · the queue is inherited parent process logger.add(..., enqueue=True) fork() child process inherits the live queue one queue, one writer lines never interleave Children only put; the parent’s worker keeps writing — nothing to re-add. spawn · a fresh interpreter parent process logger.add(..., enqueue=True) spawn() child process re-imports, inherits nothing no sink in the child its records vanish silently Fix: call logger.add(..., enqueue=True) inside each child entry point.
Inheritance is what makes the pattern work: fork hands the child a live queue, spawn hands it an empty logger you have to re-register.

Single-writer ownership also settles a question that trips teams up when they combine enqueue with rotation: because only the worker touches the file, only the worker rotates it, so there is no window in which two processes both rename and reopen the same path. That makes enqueue=True the prerequisite for safely applying the policies in best practices for log rotation in Python to a multiprocess service.

import multiprocessing as mp
from loguru import logger

logger.add("workers.log", enqueue=True, serialize=True)   # one writer in the parent


def task(i: int) -> None:
    logger.info("child work", worker=i)        # safe: pushed to the shared queue


if __name__ == "__main__":
    mp.set_start_method("fork")                # inherit the parent's queue + worker
    procs = [mp.Process(target=task, args=(i,)) for i in range(4)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()
    logger.complete()                          # drain queued child records

Expected Output (workers.log):

{"text": "...child work\n", "record": {"extra": {"worker": 0}, "process": {"id": 41182}, "message": "child work"}}
{"text": "...child work\n", "record": {"extra": {"worker": 1}, "process": {"id": 41183}, "message": "child work"}}

Each line is whole and carries its originating process.id, which is the observable proof that one writer serialized four producers.

Configuration options

Parameter on logger.add Effect Default
enqueue Route records through a process-safe queue and background worker False
catch Forward worker-thread exceptions to stderr instead of crashing True
serialize Emit each record as JSON (pairs well with enqueue) False
backtrace Extend tracebacks up the stack on exceptions False
diagnose Add variable values to tracebacks (disable in production) True
level Minimum severity the sink accepts "DEBUG"

Two shutdown calls belong with this table: logger.complete() blocks (or awaits) until all enqueued records are written, and logger.remove(sink_id) drains, then joins the worker for one sink and closes its file handle.

Choosing the enqueue settings and the matching drain Start by asking whether the sink is slow, remote or shared across processes. If not, a plain logger.add is enough. If it is, add enqueue equals True with catch left on, then branch by runtime: an asyncio service awaits logger.complete() in its lifespan shutdown, a multiprocess pool relies on inheritance under fork and re-adds the sink under spawn, and a synchronous service or CLI registers complete followed by remove with atexit. Is the sink slow, remote, or shared across processes? no no queue needed plain logger.add(sink) yes enqueue=True, catch=True one queue, one worker per sink asyncio service await logger.complete() in the lifespan shutdown multiprocess pool fork: queue inherited spawn: re-add in child sync service or CLI atexit: complete() then remove() In every branch: leave catch=True on, add serialize=True for machine-read sinks, and never let the process exit without draining the queue.
The argument set is small; what varies is where the drain belongs — a lifespan hook, a pool initializer, or an atexit callback.

Verification

Prove both properties separately: that the queue really drains, and that the caller really does not wait for the sink.

For the drain, write a batch, call logger.complete(), and count lines. The count must equal the number of records emitted.

from loguru import logger

logger.remove()
sink_id = logger.add("verify.log", enqueue=True, level="INFO")

for i in range(500):
    logger.info("event", i=i)

logger.complete()                              # ensure the queue is fully drained
logger.remove(sink_id)                         # join the worker

with open("verify.log", encoding="utf-8") as fh:
    lines = fh.readlines()
assert len(lines) == 500, f"lost records: got {len(lines)}"
print("all records flushed:", len(lines))

Expected Output:

all records flushed: 500

If the count is short, the process exited before the worker drained the queue, meaning a missing logger.complete() or logger.remove().

For the non-blocking property, add a deliberately slow callable sink and time the call site. Without enqueue the caller absorbs the sleep; with it, the caller returns in microseconds while the worker absorbs the delay during complete().

import time
from loguru import logger

def slow_sink(message) -> None:
    time.sleep(0.05)                           # stand-in for a slow network write

logger.remove()
logger.add(slow_sink, enqueue=True)

start = time.perf_counter()
for _ in range(20):
    logger.info("queued")
caller = time.perf_counter() - start
logger.complete()                              # the 1s of sink time is paid here
print(f"caller spent {caller * 1000:.1f} ms for 20 records")

Expected Output:

caller spent 3.4 ms for 20 records

Twenty records against a 50 ms sink would cost the caller a full second synchronously; a few milliseconds is the signal that dispatch is genuinely off the calling thread.

Where the second goes, measured from the verification script Bars drawn to the same scale from the verification run in this section. Without enqueue the caller is blocked for roughly 1000 milliseconds. With enqueue the caller spends 3.4 milliseconds, a two-pixel sliver at this scale, while the worker thread absorbs the full 1000 milliseconds of sink time later, during logger.complete(). Caller-side cost of 20 records against a 50 ms sink enqueue=False · caller blocked ≈1000 ms enqueue=True · caller spends 3.4 ms ← a 2 px sliver at this scale — 0.34% of the blocking cost worker thread · absorbs the same ≈1000 ms during logger.complete() 0 ms 1000 ms
The work does not disappear — enqueue moves the second of sink time off the caller and onto the worker, where it is paid at drain time.

Common mistakes

  • Error signature: the last few seconds of logs are missing after every deploy or CLI run, with no error and a truncated final line. Root cause: the interpreter exited while records were still in the queue, and the daemon worker was torn down mid-flush. Remediation: call logger.complete() — or await logger.complete() from asyncio — followed by logger.remove() in the shutdown path, and register it with atexit for short-lived workers and CLI tools.

  • Error signature: resident memory climbs steadily during a downstream outage until the process is OOM-killed, while logging calls stay fast. Root cause: enqueue keeps the caller non-blocking but the queue is unbounded, so a slow or stalled sink lets the backlog grow without limit. Remediation: put a bounded internal buffer and an explicit drop policy inside the sink itself, as shown in implementing custom sinks in Loguru, and alert on queue depth rather than on the disappearance of logs.

  • Error signature: the log file contains records from the parent only; every child process logs nothing, and the code works on Linux but not on macOS. Root cause: the spawn start method gives the child a fresh interpreter that never inherited the parent's queue or worker, so no sink exists in the child. Remediation: re-run logger.add(..., enqueue=True) inside each child's entry point (or an initializer passed to the pool), or select fork explicitly where inheritance applies.

  • Error signature: logging calls keep succeeding for hours but the sink stops receiving anything, and one traceback appeared on stderr right before the silence began. Root cause: catch=False on an enqueued sink let a single malformed record or transient sink error kill the worker thread; puts still succeed into a queue nobody drains. Remediation: leave catch=True so the worker logs the traceback and keeps draining, and treat "queue depth rising while write rate is zero" as a dead-worker alarm.

  • Error signature: PicklingError: Can't pickle <class 'sqlite3.Connection'> raised from the logger.info call itself. Root cause: an unpicklable object was attached with logger.bind(...) or passed as a keyword, and the record is pickled before it crosses the queue. Remediation: bind plain data only, converting rich objects to identifiers or strings at the call site — the same contextual-binding discipline covered in binding context variables in structlog.

Frequently Asked Questions

What does enqueue=True actually do in Loguru?

It moves the formatting and sink write off the calling thread onto a dedicated worker. Each logging call serializes the record onto a multiprocessing queue, and a background thread drains the queue and writes to the sink, so the caller never blocks on slow I/O.

Is enqueue=True required for multiprocess-safe logging?

Yes. Because enqueue uses a multiprocessing.SimpleQueue, child processes that inherit the logger push records through the same queue to one writer. Without enqueue, concurrent processes writing the same file interleave and corrupt lines.

Do I lose log records when the program exits with enqueue=True?

You can, because records sit in the queue until the worker drains them. Call logger.complete() to block until the queue is empty, or logger.remove() which drains and joins the worker, before the process exits.

Does enqueue=True make Loguru async or asyncio-aware?

enqueue uses a background thread, not asyncio. It makes logging calls non-blocking from any coroutine, but for awaitable async sinks you still call logger.complete() to await pending writes; enqueue and async sinks solve different problems.

Why must child sinks be re-added under the spawn start method?

spawn starts a fresh interpreter that does not inherit the parent's in-memory queue or worker thread, so a sink added only in the parent does not exist in the child. Re-run logger.add with enqueue=True inside each child's entry point, or use the fork start method where the handle is inherited.