skip to content

How does logging.handlers.QueueHandler with QueueListener keep slow I/O off the calling thread?

level: seniorimportance: should knowfreq 35%

answer

  1. Do the cheap half now, the slow half later
  2. The real handlers move to the listener
  3. Formatting still happens on the calling thread
  4. put_nowait means a full queue drops records
  5. Stop the listener or lose what is queued

basics

~20 s

QueueHandler only puts a prepared record on a queue.Queue and returns. A QueueListener drains that queue on a background thread and hands each record to the real handlers, so the calling thread never waits on disk or socket I/O.

solid answer

~40 s

You attach a `logging.handlers.QueueHandler` to the logger and give the real handlers — the rotating file handler, a socket handler — to a `logging.handlers.QueueListener` instead. `QueueHandler.emit()` calls `prepare()` and then `enqueue()`, which is `queue.Queue.put_nowait`; `QueueListener.start()` spawns a daemon thread that pops records and calls each handler's `handle()`. Two consequences matter. First, `prepare()` formats the message and clears `args`, `exc_info` and `stack_info`, so the record is picklable and can cross a `multiprocessing.Queue` — but it also means the string formatting cost stays on the calling thread. Second, the queue's capacity is a policy decision: bounded means `queue.Full` is raised, caught by `emit()`, and routed to `handleError()`, so the record is dropped; unbounded means the backlog grows in memory instead. Always call `QueueListener.stop()` at shutdown, or whatever is still queued is lost.

code

python · 16 lines
python
import logging
import logging.handlers
import queue

log_queue = queue.Queue(maxsize=10_000)
sink = logging.StreamHandler()
sink.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
listener = logging.handlers.QueueListener(log_queue, sink, respect_handler_level=True)
listener.start()

logger = logging.getLogger("etl.export")
logger.setLevel(logging.INFO)
logger.addHandler(logging.handlers.QueueHandler(log_queue))
logger.info("queued row batch %d", 42)

listener.stop()

go deeper

for a junior

Recall the shape: the logger gets a queue handler, the real handlers move to a listener, and the listener runs on its own thread. Knowing why anyone would want that — logging should not make working code wait on a disk — is enough here.

for a middle

Explain the flow through prepare, enqueue and the listener thread, and why prepare clears args and exception info. Be able to say what start and stop each do.

for a senior

Show judgement about the queue: bounded and dropping, unbounded and growing, or blocking and applying backpressure, and how you would know which is happening in production. Own the shutdown ordering.

for a principal

Frame it as a reliability tradeoff for the platform: how much log loss is acceptable under pressure, whether that budget is uniform across services, and where the single writer lives so that no request path ever waits on log I/O.

### The problem it solves A `logging.Handler` does its work synchronously, on the thread that called `logger.info(...)`, while holding the handler's lock. If that work is a write to a file on a slow disk, a rotation, a DNS lookup or a socket connect, the thread doing real work waits for it — and every other thread that wants the same handler queues behind the lock. In an asyncio program the consequence is sharper still: a blocking handler stalls the event loop, and every task on it, for the duration of the write. `logging.handlers.QueueHandler` and `logging.handlers.QueueListener` split the handler in two. The logger-side half does the cheapest possible thing and returns; a background thread does the expensive half. ### The wiring ```python import logging import logging.handlers import queue log_queue = queue.Queue(maxsize=10_000) sink = logging.handlers.RotatingFileHandler( "etl-export.log", maxBytes=10_000_000, backupCount=5, encoding="utf-8" ) listener = logging.handlers.QueueListener(log_queue, sink, respect_handler_level=True) listener.start() logger = logging.getLogger("etl.export") logger.addHandler(logging.handlers.QueueHandler(log_queue)) ``` The real handler is attached to the **listener**, never to the logger — attaching it to both writes every line twice. `respect_handler_level=True` makes the listener honour each sink's own level; without it the listener hands every dequeued record to every handler regardless of level. ### What prepare() does, and why it matters `QueueHandler.emit()` is three lines: `self.enqueue(self.prepare(record))`, wrapped in a `try` whose `except` calls `handleError(record)`. `prepare()` formats the record with the handler's formatter, copies the `logging.LogRecord`, writes the merged text into `msg` and `message`, and sets `args`, `exc_info`, `exc_text` and `stack_info` to `None`. Two things follow. **Good:** the record becomes picklable, so the same design works across processes with a `multiprocessing.Queue`, and a traceback is already rendered into text before the record travels. **Less obvious:** the `%`-style argument merging happens on the calling thread, not the listener thread. The queue moves the *I/O* off the hot path, not the formatting. It also means the listener's handlers see a record whose arguments are already gone, so a formatter downstream can only re-arrange the fields around an already-rendered message. ### The queue is a policy decision `enqueue()` uses `queue.Queue.put_nowait`. On a bounded queue that raises `queue.Full` the moment the listener falls behind; `emit()` catches it and calls `handleError()`, which drops the record and — while `logging.raiseExceptions` is true — prints a traceback to `sys.stderr`. So: * **Bounded queue:** logging degrades by losing records under pressure, and memory stays flat. Usually the right choice for a service, provided you know it is happening; a small `enqueue()` override that counts drops turns silent loss into a metric. * **Unbounded queue:** no record is ever dropped, and a listener that cannot keep up turns your log backlog into unbounded memory growth. This is not backpressure; it is a slow leak with a deadline. * **Blocking put:** overriding `enqueue()` to use a blocking `put` with a timeout gives real backpressure, at the cost of making a logging call able to stall application work — which is exactly what the queue was introduced to prevent. Choose it deliberately, not by accident. ### Shutdown is not optional `QueueListener.start()` creates a daemon thread. At interpreter exit a daemon thread is simply abandoned, so any records still sitting in the queue are lost, and `logging.shutdown()` does not help: it flushes handlers, and the queue is not a handler's buffer. Call `QueueListener.stop()` on your shutdown path — it enqueues a sentinel and joins the thread, so everything already queued is written first. Registering it with `atexit.register` or using the listener as a context manager, which its `__enter__`/`__exit__` support allows, both work. Order matters: stop the listener *after* the components that log, or you lose exactly the shutdown messages you most want. ### Where it fits with the other handlers This is also the standard answer to multiple processes needing one rotating file: workers attach a `QueueHandler` over a `multiprocessing.Queue`, one listener in the parent owns the single rotating handler, and only that one process ever renames a file. The queue buys three things at once — the disk write leaves the worker, the rotation gets a single owner, and the ordering of the merged file is decided in one place. In an asyncio service the same wiring is what keeps a file write out of the event loop: the coroutine calling `logger.info(...)` performs a `put_nowait` and continues, and the write happens on the listener's thread. Nothing about the pair is asyncio-specific — there is no async handler protocol here — which is exactly why it is the standard answer for that case too.

  • What exactly happens to a record when the bounded queue.Queue is already full?
    `enqueue()` calls `put_nowait`, which raises `queue.Full`. `QueueHandler.emit()` catches it and calls `handleError()`, so the record is dropped and a traceback goes to `sys.stderr` while `logging.raiseExceptions` is true. Nothing retries and nothing is counted, which is why an override of `enqueue()` that increments a drop counter is worth the ten lines.
  • What breaks if you never call QueueListener.stop()?
    The listener runs on a daemon thread, so at interpreter exit it is abandoned mid-queue and everything still buffered is lost — typically the shutdown and crash messages you most need. `logging.shutdown()` does not drain it, because the queue is not part of any handler's buffer. Call `stop()` explicitly on the shutdown path, or via `atexit.register`, after the components that log have finished.
  • Does the queue move formatting off the calling thread too?
    No. `prepare()` runs the handler's formatter on the calling thread so that the merged message and the rendered traceback travel instead of unpicklable objects. The queue moves the I/O — the write, the rotation, the socket — not the string work. If formatting itself is the cost, the fix is a cheaper formatter or a level check, not a queue.

saying these in an interview costs you the question

  • Attaches the real handler to both the logger and the listener
  • Assumes the listener thread is joined automatically at exit
  • Calls an unbounded queue backpressure
  • Thinks message formatting is deferred to the listener thread
  • Believes a full queue blocks the caller by default
  • Expects the listener to honour handler levels without being told

context