Handler Architecture for Python Logging

Handler architecture defines how a LogRecord is routed, filtered, and dispatched to its destinations. The decisive design choice in any high-throughput Python service is separating log emission from the I/O that writes it, because synchronous disk and network writes on a request thread translate directly into tail-latency spikes. This guide is part of the Python Logging and Structured Data reference and covers non-blocking dispatch, backpressure control, failure isolation, and trace correlation across the queue boundary. It builds on formatter configuration for serialization and on log levels and severity mapping for routing decisions, and it drills into non-blocking logging with QueueHandler and log rotation in Python for the two sinks that most often stall a request thread.

Non-blocking handler fan-out across the queue boundary On the producer side, the request thread holds a single QueueHandler with a context filter and enqueues each record into a bounded queue of ten thousand slots, returning in microseconds. On the consumer side, a QueueListener running one drain thread pulls records off the queue and fans them out to three sinks: stdout at INFO and above as JSON, an errors.log file at ERROR and above as text, and a batched OTLP exporter. When the queue is full the handler sheds DEBUG and INFO records and keeps ERROR and CRITICAL. producer side · request thread consumer side · listener thread thread boundary request thread QueueHandler + context filter bounded queue maxsize = 10 000 QueueListener one drain thread respect_handler_level stdout INFO+ · JSON errors.log ERROR+ · text OTLP exporter batched export returns in microseconds no I/O on this thread on queue.Full: shed DEBUG/INFO keep ERROR and CRITICAL formats and writes here one slow sink delays the rest
The QueueHandler returns immediately; a single background listener drains the bounded queue into severity-filtered sinks.

Key architectural principles:

  • Decouple log emission from disk and network writes so the calling thread never blocks.
  • Deploy one handler per sink with independent filters and formatters to isolate failure domains.
  • Bound every queue and define an explicit overflow policy to prevent memory exhaustion.
  • Inject trace context through filters on the producing side rather than mutating global state.
  • Make configuration declarative so the handler graph can be rebuilt without touching call sites.

Prerequisites

Handler decoupling and trace injection use only the standard library; the OTLP sink requires the OpenTelemetry SDK. Pin versions so opentelemetry-api and opentelemetry-sdk stay aligned — the logs SDK still moves between minor releases, and a skew between the API and the exporter is the usual cause of a handler that silently exports nothing.

pip install \
  "opentelemetry-sdk>=1.30.0,<2.0.0" \
  "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"

Drive the sink addresses and severity floor from the environment so the same module runs unchanged in every deployment tier, rather than branching on a hardcoded DEBUG flag.

export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
export OTEL_SERVICE_NAME="payments-api"
export LOG_LEVEL="INFO"
export LOG_QUEUE_MAXSIZE="10000"

Concept and Architecture

A handler is the object that takes a filtered LogRecord and emits it to a destination. When you call logger.info(), the logger builds a record, runs its own filters, and then walks itself and every ancestor calling callHandlers(). Each handler whose level and filters accept the record gets handle() invoked, which acquires that handler's lock, formats the record, and writes it. Two properties of that walk drive the whole design: dispatch is synchronous on the calling thread, and it visits ancestors unless a logger sets propagate = False.

Synchronous dispatch is cheap for a StreamHandler writing to a pipe, but the cost is unbounded for anything else. A FileHandler blocks during a rollover rename; a SysLogHandler blocks on a socket write; an OTLP exporter blocks while a slow collector applies backpressure. Whatever that write costs lands on the request path, and because Handler.handle() holds a per-handler lock, one slow sink also serializes every other thread trying to log through it. Under concurrency this shows up as a P99 that tracks your logging backend's health rather than your application's.

How callHandlers walks the logger hierarchy A call to logger.info builds a LogRecord at the payment.service logger. That logger's level and filters admit the record, then callHandlers dispatches it to the QueueHandler, where the handler's own level must be at or below the record level and every handler filter must return True before emit runs on the calling thread. Because payment.service sets propagate to False, the walk stops there: the parent payment logger and the root logger are never visited, so handlers attached to them cannot emit a second copy. logger.info("charged") LogRecord built here payment.service level = INFO · filters run handler: QueueHandler payment level = NOTSET never visited root level = WARNING never visited propagate = False walk already stopped logger level checked first callHandlers(): the per-handler gate handler.level ≤ record.levelno, then filters return True only then does emit() run — synchronously, on this thread what propagate = False buys you The record stops at the logger that owns handlers, so no ancestor handler can emit a second copy of it. Leave it True with handlers on both a child and the root and every line ships twice — inflating every count you later derive from the stream. Libraries attach NullHandler() and leave propagation alone.
Dispatch walks the ancestor chain until a logger sets propagate = False; at every level the handler's own level and filters gate the write, and all of it runs on the calling thread.

The structural fix is to make the only handler on the application logger a QueueHandler. Its emit() does nothing but put the record on a queue.Queue, which is a lock-protected, constant-time operation with no I/O. A separate QueueListener runs on a background thread, pulls records off the queue, and dispatches them to the real handlers. This inverts the cost model: the request thread pays a few microseconds for the enqueue, and one dedicated thread absorbs all serialization and I/O for the whole process. The detailed mechanics, sentinel-based shutdown ordering, and respect_handler_level semantics are covered in non-blocking logging with QueueHandler.

What actually crosses the queue matters more than most teams expect. QueueHandler.prepare() calls self.format(record), makes a shallow copy of the record, and then sets args, exc_info, exc_text, and stack_info to None so the object is guaranteed picklable. That is correct for a multiprocessing.Queue, where the record really is serialized, but it has a sharp edge for structured output: by the time your JSON formatter runs on the listener thread, record.exc_info is None and the traceback has been concatenated onto record.getMessage(). If you are producing structured logs with a dedicated exception field, override prepare() to return the record unchanged — with an in-process queue.Queue nothing is pickled, the same object crosses by reference, and lazy %-style arguments survive intact.

Filters refine the graph further. A logging.Filter attached to a handler decides, per record, whether that handler emits it, and it runs before the formatter — so an expensive JSON serialization is skipped entirely for records a sink rejects. There is an important distinction between a handler's level and a filter: the level is a single floor compared against record.levelno, while a filter is arbitrary per-record logic that may also enrich the record by mutating it. That mutation ability is what makes filters the right place to attach trace identifiers.

The queue boundary decides where each filter must live. Filters attached to a logger or to the QueueHandler run during record creation, on the calling thread, where request-scoped state still exists. Filters attached to handlers owned by the QueueListener run on the listener thread, which never had that state. Read contextvars in a handler-side filter and you will get the defaults every time, because the listener thread has its own empty context. Attach context-reading filters to the producing side, and reserve handler-side filters for routing decisions that depend only on data already on the record. The full rules for propagating that state safely are in context variables and thread safety.

One last structural rule: exactly one component should own handlers. Application code configures the root logger (or a single top-level application logger) and lets everything propagate up; library code attaches logging.NullHandler() and nothing else. Handlers attached in several places are the single most common cause of duplicated log lines, and duplication corrupts every count you later derive from the log stream.

Choosing a Handler for Each Sink

Because the listener owns the concrete handlers, you can pick the right class per destination without any of them touching the request path. The choice still matters, though: the listener thread is shared, so a handler that blocks for thirty seconds stalls every other sink behind it until the timeout expires.

Blocking cost of the standard library handler classes Four bands ordered by the cost of a single emit call on the shared listener thread. Low, an in-memory or pipe write: StreamHandler, WatchedFileHandler, MemoryHandler and NullHandler, all safe even on a request thread. Medium, local disk or socket: RotatingFileHandler, TimedRotatingFileHandler, SysLogHandler and the OpenTelemetry LoggingHandler, which belong on the listener and whose rollover cost must be watched. High, a network round trip: SocketHandler, DatagramHandler and HTTPHandler, which need batching and a retry budget. Very high: SMTPHandler, which should never sit in the log path. The rotating file handlers are marked as unsafe when several processes write the same file. cost of one emit() on the shared listener thread Low in-memory or pipe write StreamHandler WatchedFileHandler MemoryHandler NullHandler safe even on the request thread Medium local disk or socket RotatingFileHandler * TimedRotatingFileHandler * SysLogHandler OTel LoggingHandler listener only — the rollover lands here High network round trip SocketHandler DatagramHandler HTTPHandler needs batching and a retry budget Very high third-party service SMTPHandler one blocking SMTP session per record, inside emit() never in the log path — alert from metrics * not multi-process safe: several processes writing one file interleave records and race the rollover
Ordered by what one emit() costs the listener thread — everything to the right of the first band shares that single thread, so a stalled sink eats the headroom of the others.
Sink Handler class Blocking risk Notes
Container stdout StreamHandler Low (pipe write) The default in Kubernetes; the runtime owns collection and rotation.
Local file, externally rotated WatchedFileHandler Low Reopens when logrotate replaces the inode; POSIX only.
Local file, self-rotated RotatingFileHandler Medium (rename on rollover) Single-process only; see log rotation best practices.
Daily file TimedRotatingFileHandler Medium Rollover cost lands on whichever emit crosses the boundary.
Host syslog SysLogHandler Medium Prefer the local /dev/log socket over UDP to a remote host.
Remote aggregator SocketHandler High Sends pickled records; the receiver must be trusted.
Webhook or HTTP API HTTPHandler High No retry, no batching; wrap it or export via a collector instead.
Alert email SMTPHandler Very high Never attach it directly; route alerts from your metrics stack.
Error-triggered buffer MemoryHandler Low Holds records until an ERROR flushes the preceding context.
Library default NullHandler None Prevents "no handlers could be found" without imposing config.
OTLP logs pipeline LoggingHandler (OTel SDK) Medium Batches via the SDK's log record processor; correlates with distributed traces.

MemoryHandler deserves a specific mention because it solves a problem no other handler does. Wrapping a target handler in a MemoryHandler with capacity=100 and flushLevel=logging.ERROR gives you a rolling buffer of DEBUG context that is written only when something actually fails. You get full-fidelity debugging around incidents without paying to ingest debug records during normal operation — a far better trade than globally raising the level to DEBUG and hoping the bill is acceptable.

Step-by-Step Implementation

Assembly order for the handler graph Six numbered steps in build order. One, a context filter that reads contextvars on the thread that made the record, running on the producer side. Two, a severity router that inspects only the record and is therefore safe on the listener side. Three, one handler per sink, each with its own formatter, level and failure domain, owned by the listener. Four, a bounded queue whose maxsize comes from the peak emission rate, with put_nowait and a drop policy. Five, the QueueHandler as the only handler on the logger, with the listener owning the sinks. Six, a shutdown hook that calls listener.stop from atexit so the sentinel drains the queue before the process exits. build order for the six steps below 1 context filter reads contextvars on the thread that built the record producer side 2 severity router inspects the record only, never ambient state listener side 3 one handler per sink own formatter, own level, own failure domain listener side 4 bounded queue maxsize from peak rate, put_nowait + drop policy the boundary 5 wire and start QueueHandler is the only handler on the logger producer side 6 shutdown hook atexit → listener.stop() drains, then joins process exit order matters: the listener must own its handlers before the QueueHandler is attached to the logger
Filters and sinks are built first, the queue and listener are wired around them, and the flush hook is registered last.

Step 1 — Inject trace context with a filter. Read the active trace_id and span_id from contextvars and attach them as record attributes. This keeps trace correlation out of every call site and out of global mutable state, and it runs on the producing thread where the context still exists.

import contextvars
import logging

trace_id_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar("trace_id", default=None)
span_id_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar("span_id", default=None)


class OTelTraceFilter(logging.Filter):
    """Inject W3C Trace Context identifiers into every record."""
    def filter(self, record: logging.LogRecord) -> bool:
        # All-zero IDs are the W3C-invalid sentinel: parseable, obviously absent.
        record.trace_id = trace_id_ctx.get() or "0" * 32
        record.span_id = span_id_ctx.get() or "0" * 16
        return True

Step 2 — Route by severity with a second filter. A range filter lets one sink take everything from INFO up while another takes only ERROR and above. Unlike setLevel(), a range can also exclude high severities, which is how you keep a noisy debug sink from mirroring your error file.

class SeverityRouter(logging.Filter):
    """Pass records whose level is within [min_level, max_level]."""
    def __init__(self, min_level: int, max_level: int):
        super().__init__()
        self.min_level = min_level
        self.max_level = max_level

    def filter(self, record: logging.LogRecord) -> bool:
        # Runs on the listener thread: inspect the record only, never ambient state.
        return self.min_level <= record.levelno <= self.max_level

Step 3 — Build per-sink handlers. Each handler gets its own formatter and the filters it needs. A failure inside one handler's emit() cannot corrupt another, and each sink's serialization format is independent: compact JSON for the machine-read stream, a human-readable line for the on-call file.

import sys

stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(logging.Formatter(
    '{"time":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s",'
    '"trace_id":"%(trace_id)s","span_id":"%(span_id)s"}'
))
stdout_handler.addFilter(SeverityRouter(logging.INFO, logging.CRITICAL))

error_handler = logging.FileHandler("errors.log", delay=True)
error_handler.setFormatter(logging.Formatter(
    "%(asctime)s [%(levelname)s] %(name)s - %(message)s (trace=%(trace_id)s)"
))
error_handler.addFilter(SeverityRouter(logging.ERROR, logging.CRITICAL))

Step 4 — Bound the queue and define the drop policy. QueueHandler.enqueue() calls put_nowait() already, but a plain queue.Queue() is unbounded, so saturation manifests as unbounded memory growth instead of an exception. Give the queue a maxsize and replace enqueue with a policy that decides explicitly what to sacrifice.

import queue
from logging.handlers import QueueHandler, QueueListener


class SheddingQueueHandler(QueueHandler):
    """Never block the caller; shed low-severity records under saturation."""

    def __init__(self, log_queue: queue.Queue):
        super().__init__(log_queue)
        self.dropped = 0

    def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
        # In-process queue: nothing is pickled, so keep exc_info and args intact
        # for the downstream formatters instead of flattening them here.
        return record

    def enqueue(self, record: logging.LogRecord) -> None:
        try:
            self.queue.put_nowait(record)
        except queue.Full:
            if record.levelno >= logging.ERROR:
                try:
                    self.queue.get_nowait()          # evict the oldest record
                    self.queue.put_nowait(record)    # keep the error
                except (queue.Empty, queue.Full):
                    self.dropped += 1
            else:
                self.dropped += 1                    # shed DEBUG/INFO/WARNING

Step 5 — Wire the listener. Attach only the queue handler to the logger and let the QueueListener own the concrete handlers. Set respect_handler_level=True so each handler's own level is honoured on the drain side; it defaults to False, which quietly bypasses every setLevel() you configured on the sinks.

import os


def setup_handlers() -> tuple[SheddingQueueHandler, QueueListener]:
    maxsize = int(os.getenv("LOG_QUEUE_MAXSIZE", "10000"))
    log_queue: queue.Queue = queue.Queue(maxsize=maxsize)

    listener = QueueListener(
        log_queue, stdout_handler, error_handler, respect_handler_level=True
    )
    listener.start()                       # spawns the single draining thread

    queue_handler = SheddingQueueHandler(log_queue)
    queue_handler.addFilter(OTelTraceFilter())   # producer side: context is live
    return queue_handler, listener

Step 6 — Attach, run, and flush on shutdown. Set propagate = False on any logger that owns handlers, and register listener.stop() so the sentinel drains the queue before the process exits.

import atexit
import time

if __name__ == "__main__":
    handler, listener = setup_handlers()
    atexit.register(listener.stop)         # flush buffered records on exit

    logger = logging.getLogger("payment.service")
    logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))
    logger.addHandler(handler)
    logger.propagate = False               # the root logger must not double-emit

    trace_id_ctx.set("4bf92f3577b34da6a3ce929d0e0e4736")
    span_id_ctx.set("00f067aa0ba902b7")

    logger.info("Transaction initiated")
    logger.warning("Retry attempt 1")
    logger.error("Payment gateway timeout")

    time.sleep(0.1)                        # let the background thread drain

Expected Output: (newline-delimited JSON on stdout, Python 3.12)

{"time":"2026-07-25 14:32:11,123","level":"INFO","msg":"Transaction initiated","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
{"time":"2026-07-25 14:32:11,124","level":"WARNING","msg":"Retry attempt 1","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
{"time":"2026-07-25 14:32:11,125","level":"ERROR","msg":"Payment gateway timeout","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}

errors.log receives only the ERROR record, because its SeverityRouter rejects everything below ERROR. Note that delay=True means the file is not even created until that first error arrives, which keeps idle workers from littering empty log files across the filesystem.

Configuration Reference

Parameter map of the queue path Three components in a row. The QueueHandler on the application logger takes a queue, whose default is any queue-like object but which should be a bounded queue.Queue; a prepare method that by default flattens exc_info and args and should be overridden to return the record untouched; and filters, none by default, where the context filter belongs. The queue.Queue itself has maxsize, defaulting to zero meaning unbounded and which should be the peak emission rate multiplied by the stall you want to absorb; a queue.Full path where put_nowait raises and should instead shed DEBUG and INFO while keeping ERROR; and a depth that is not exported by default but should be scraped as the backpressure signal. The QueueListener takes the handlers, one per sink with its own formatter; respect_handler_level, which defaults to False and should be True so per-handler setLevel calls are honoured; and stop, never called for you, which belongs in an atexit or shutdown hook. QueueHandler on the app logger queue.Queue the buffer itself QueueListener background drain put_nowait() get() queue any queue-like, required a bounded queue.Queue prepare() flattens exc_info + args override → return record filters none context filter runs here maxsize 0 — unbounded peak rate × stall seconds on queue.Full put_nowait() raises shed DEBUG/INFO, keep ERROR depth not exported scrape it as backpressure *handlers required, positional one per sink, own formatter respect_handler_level False True — honour setLevel() stop() never called for you atexit or shutdown hook each knob: the standard library default first, the production setting below it
The three objects on the queue path and the knob on each one that separates a demo from a production configuration.
Component Parameter Type / default Production guidance
QueueHandler queue queue-like, required Always a bounded queue.Queue(maxsize=N), never an unbounded one.
QueueHandler prepare() method Override to a no-op for in-process queues so exc_info and args survive.
queue.Queue maxsize int, 0 (unbounded) Size to 2–5 seconds of peak emission rate; 0 is an out-of-memory waiting to happen.
QueueListener *handlers handlers, required One per destination, each with its own formatter and filters.
QueueListener respect_handler_level bool, False Set True, or per-handler setLevel() calls are ignored on drain.
QueueListener stop() method Call from atexit or the framework shutdown hook; it drains, then joins.
Logger level int, NOTSET The cheapest filter in the system — set it from LOG_LEVEL at startup.
Logger propagate bool, True False on any logger that owns handlers, to prevent duplicate emission.
Handler level int, NOTSET (0) The per-sink floor; pairs with respect_handler_level=True.
Handler formatter Formatter, basic One instance per handler, constructed once at configuration time.
FileHandler delay bool, False True so unused workers never create empty files.
MemoryHandler capacity / flushLevel int / int, ERROR Buffer ~100 records and flush on ERROR for pre-incident debug context.
logging raiseExceptions bool, True Leave True in staging to surface handler bugs; it prints, it never propagates.

Backpressure and Overflow Policy

High request rates expose the queue to saturation whenever a downstream sink slows. Queue depth is your backpressure signal: a steadily growing queue means the listener cannot drain as fast as producers enqueue, almost always because one sink — typically the network exporter — has stalled. Because the listener is a single thread, the drain rate is bounded by the sum of every handler's per-record cost, so adding a slow fourth sink reduces the headroom of the other three.

Queue depth during a collector stall A line chart of queue depth against time. For the first ten seconds the depth sits near 150 records out of a 10 000 slot ceiling. The collector stalls at ten seconds and the depth climbs to the maxsize ceiling in about three seconds — that is the absorb window the queue was sized for. From thirteen to twenty-two seconds the queue is full and the drop policy sheds DEBUG and INFO records, so a cumulative dropped-records counter rises for the whole window. When the collector recovers the listener drains the backlog in roughly four seconds and depth returns to baseline. maxsize = 10 000 absorb shedding DEBUG / INFO dropped steady state: depth ≈ 150 records the listener drains as fast as we emit dropped records (cumulative) 0 5 000 10 000 queue depth 0 10 s 20 s 30 s queue depth records shed maxsize ceiling sized here for a 3 s stall at 3 300 records/s
The queue buys exactly maxsize ÷ emission rate seconds of stall; past that the drop policy decides what survives, and the shed counter is the only signal that it engaged.

Size maxsize from measurement, not intuition. Multiply your peak emission rate by the longest sink stall you want to absorb without loss: a service emitting 2,000 records per second that should ride out a three-second collector hiccup needs roughly 6,000 slots. Then sanity-check the memory: a LogRecord with a modest message costs on the order of a kilobyte once formatted, so 10,000 slots is single-digit megabytes — cheap enough to be generous, but not so large that a sustained outage buffers gigabytes before the drop policy ever engages.

The overflow policy is a deliberate trade-off, not an error path to ignore. Dropping DEBUG and INFO under saturation preserves the ERROR and CRITICAL records that incident response actually needs, and it stops the logging subsystem from being the cause of an out-of-memory kill during an unrelated outage. Emit a counter each time a record is shed so the loss is visible rather than silent; the dropped attribute in Step 4 exists precisely so it can be scraped. A pipeline that quietly discards records during exactly the incidents you are trying to debug is worse than one that is merely slow.

Choosing which severities to shed is a policy decision that belongs alongside your severity conventions — if WARNING is used for genuinely actionable conditions in your codebase, shedding it is wrong, and the fix is upstream in log levels and severity mapping. The cheapest backpressure control of all is not emitting the record: a logger-level setLevel(logging.INFO) discards debug records before a LogRecord object is even constructed, which costs less than any queue policy can.

Failure Isolation and Handler Errors

Keeping one handler per sink confines failure. If the file handler raises during rollover or the OTLP exporter times out, the exception is contained to that handler's emit(), and stdout keeps receiving records. A single multiplexing handler couples these fates together, so an outage in the slowest sink stalls every destination behind it.

Failure domains: one handler per sink versus one multiplexing handler On the left, the QueueListener owns three independent handlers. The OTLP exporter raises inside emit, Handler.handle catches it and routes it to handleError, and the stdout and errors.log handlers keep draining because the fault never leaves that handler's own emit call. On the right, one multiplexing handler writes every destination inside a single emit call, so the same blocking network write and the same exception take stdout and the error file down with it. the listener thread is shared — containment is a property of the handler graph, not of the thread isolated: one handler per sink QueueListener stdout — draining errors.log — draining OTLP — faulted emit() raises handle() catches it → handleError() prints to stderr the other two sinks never notice coupled: one handler, many sinks one multiplexing handler a single emit() writes every destination the network write blocks inside emit() the exception aborts the rest of that emit, and the handler lock is held the whole time stdout and the error file are starved too one fault takes down every destination
A fault is contained by the handler it happens in — which is why each sink gets its own handler instead of sharing a multiplexing one.

The standard library backs this up at the class level. Handler.handle() wraps emit() so that an exception raised inside a handler is caught and routed to handleError(), which prints a traceback to sys.stderr when logging.raiseExceptions is true and otherwise does nothing. That is why a broken sink degrades rather than crashing your request. It also means handler bugs are easy to miss: leave raiseExceptions = True in staging so mistakes are loud, and be aware that in production the failure is visible only on stderr.

The one place this safety net does not reach is code that runs outside emit(). A custom handler that does work in __init__ on first use, or a filter that raises, can kill the QueueListener thread — and because that thread is not the main thread, the process keeps serving traffic while logging silently stops. Guard custom filter and handler logic with its own try/except, and expose a liveness signal (the listener thread's is_alive(), or a heartbeat counter of records drained) so a dead listener is detectable rather than merely mysterious.

Finally, keep sinks independent in their configuration, not just their code. Two handlers writing to the same file, or a rotating handler shared between processes, reintroduces a coupling the architecture was meant to remove; the multi-process case has its own rules in thread-safe and multiprocessing-safe logging.

Async and Concurrency Considerations

Standard logging emission is thread-safe — every handler holds a lock during emit() — but thread safety is not the same as non-blocking. The QueueHandler pattern is what makes logging safe for an asyncio event loop: because enqueue is constant-time and does no I/O, a coroutine that logs never yields control to disk or network latency, and the loop's other tasks are unaffected. The single QueueListener thread becomes the only place blocking I/O happens, which as a bonus serializes writes to a shared file without any additional locking.

Event loop timeline with and without the queue Two timelines over the same ten millisecond window. In the first, a coroutine calls a logger whose FileHandler is attached directly, so an eight millisecond blocking write runs inside emit on the event loop and the next task cannot start until it returns. In the second, the same call goes through a QueueHandler: the enqueue costs roughly twenty microseconds, the loop keeps scheduling tasks with no I/O on that thread, and the identical eight millisecond write is performed on the listener thread instead. A · FileHandler attached straight to the logger event loop task A blocking write inside emit() — 8 ms task B task B cannot start until the write returns — the whole loop waits B · QueueHandler on the logger, sinks on the listener enqueue ≈ 20 µs event loop task A task B task C task D the loop keeps scheduling — no I/O on this thread listener thread the same 8 ms write, off the event loop the coroutine resumed 8 ms earlier; the write still happens, just not here 0 4 ms 8 ms 10 ms
The write costs the same either way; the queue moves it off the thread that has to answer the request.

Context variables are the correct carrier for request-scoped trace data in async code: they propagate across await points within a task and are snapshotted when a child task is created with create_task(). A worker thread, however, does not inherit the event loop's context. When you offload work to a ThreadPoolExecutor via run_in_executor, capture the context with contextvars.copy_context() and invoke the callable through ctx.run(...), or use asyncio.to_thread(), which performs that copy for you. Skip it and the trace filter reads its defaults, so the offloaded work logs all-zero identifiers.

The same reasoning explains why the trace filter belongs on the QueueHandler and not on the sinks. The listener thread is created once at startup, long before any request exists, and it never enters a request's context — so a contextvars read there is guaranteed to miss. Attach the enrichment where the record is born; by the time the record reaches the listener, trace_id is a plain attribute that any formatter can interpolate. The full propagation model, including task groups and thread boundaries, is covered in context variables and thread safety, and the resulting fields are described in adding trace IDs to log records.

Process boundaries need a different answer entirely. Under fork(), a QueueListener thread does not survive into the child — only the forking thread continues — so a worker that inherits a configured logger enqueues records that nobody drains. Build the queue and start the listener after the fork, in a post_fork or worker_process_init hook, or switch to a multiprocessing.Queue with a single listener in the parent. Third-party libraries solve the same problem their own way; Loguru's enqueue=True is the direct analogue, described in async and non-blocking logging with Loguru.

Production Code Examples

Declarative queue topology with dictConfig

Building handlers imperatively is fine for illustration, but production configuration should be data. Python 3.12 taught dictConfig to construct the queue path directly: a handler with class: logging.handlers.QueueHandler accepts a handlers list naming the sinks the listener should own, and dictConfig creates the listener for you and exposes it as handler.listener. The declarative form is covered end to end in logging configuration and dictConfig.

import atexit
import logging
import logging.config
import os

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {
        "trace": {"()": "myapp.logging_setup.OTelTraceFilter"},
    },
    "formatters": {
        "json": {
            "format": '{"time":"%(asctime)s","level":"%(levelname)s",'
                      '"logger":"%(name)s","msg":"%(message)s",'
                      '"trace_id":"%(trace_id)s"}',
        },
        "plain": {"format": "%(asctime)s [%(levelname)s] %(name)s - %(message)s"},
    },
    "handlers": {
        "stdout": {                       # owned by the listener, not the logger
            "class": "logging.StreamHandler",
            "stream": "ext://sys.stdout",
            "formatter": "json",
            "level": "INFO",
        },
        "errors": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "errors.log",
            "maxBytes": 10_485_760,       # 10 MiB
            "backupCount": 5,
            "delay": True,
            "formatter": "plain",
            "level": "ERROR",
        },
        "queue": {                        # the only handler the app logger sees
            "class": "logging.handlers.QueueHandler",
            "filters": ["trace"],         # producer side: context still live
            "handlers": ["stdout", "errors"],
            "respect_handler_level": True,
        },
    },
    "root": {"level": os.getenv("LOG_LEVEL", "INFO"), "handlers": ["queue"]},
}

logging.config.dictConfig(LOGGING)
listener = logging.getHandlerByName("queue").listener   # 3.12+
listener.start()
atexit.register(listener.stop)

logging.getLogger("payment.service").info("checkout completed", extra={"order": "o-91"})

Expected Output: (one compact JSON line on stdout; errors.log stays uncreated because delay=True and no error was emitted)

{"time":"2026-07-25 14:40:02,881","level":"INFO","logger":"payment.service","msg":"checkout completed","trace_id":"00000000000000000000000000000000"}

Atomic listener swap for zero-loss reconfiguration

Because the logger only ever holds a QueueHandler, you can rebuild the entire sink topology — add an OTLP exporter, drop a file sink, change formats — by draining the old listener and starting a new one against the same queue. The handler the application logs through is never detached, so no record emitted during the swap is lost: worst case it waits in the queue until the new listener starts draining.

import logging
import queue
from logging.handlers import QueueHandler, QueueListener


def reconfigure(log_queue: queue.Queue, old: QueueListener,
                *new_handlers: logging.Handler) -> QueueListener:
    """Swap the sink topology without touching the logger's QueueHandler."""
    old.stop()                              # sentinel: drains, then joins the thread
    for handler in old.handlers:
        handler.close()                     # release file descriptors and sockets
    new = QueueListener(log_queue, *new_handlers, respect_handler_level=True)
    new.start()
    logging.getLogger(__name__).debug(
        "listener reconfigured with %d sinks", len(new_handlers)
    )
    return new

Expected Output: (records emitted mid-swap are buffered, then drained by the new sinks)

2026-07-25 14:41:19,004 [DEBUG] myapp.logging_setup - listener reconfigured with 3 sinks

Two details make this safe. QueueListener.stop() enqueues a sentinel and joins the thread, so every record already in the queue is dispatched to the old handlers before they close — you never lose the tail. And closing the old handlers explicitly matters when they hold file descriptors or sockets; stop() alone leaves them open, and a service that reloads configuration on SIGHUP will leak one descriptor set per reload otherwise.

What each participant does during an atomic listener swap Three lanes across four phases. The QueueHandler on the logger is never detached at any point, so application code keeps emitting throughout. The queue drains normally in phase one; in phase two the sentinel from stop is enqueued and records keep arriving behind it; in phase three, with no listener running, records simply buffer; in phase four the new listener drains the same queue object again. The listener lane shows the old listener draining, then draining and joining on the sentinel, then a gap while the old handlers are closed to release file descriptors and sockets, then the new listener with its new sinks attached. 1 · steady state 2 · old.stop() 3 · handlers closed 4 · new listener logger + QueueHandler never detached — application code keeps emitting throughout queue drains normally sentinel enqueued records buffer here drains again listener old listener draining drains, then joins no listener running new sinks attached records keep arriving close() releases fds same queue object nothing is lost: the worst case for a record emitted mid-swap is a short wait in the queue
The logger's attachment point never moves, so the sink topology can be rebuilt underneath it without losing the records emitted during the gap.

Common Mistakes

Diagnosing a broken handler graph Starting from missing or duplicated records, five branches. Duplicate lines suggest handlers attached in more than one place: attach them once and set propagate to False, with libraries adding only a NullHandler. A lost tail at shutdown suggests listener.stop was never called: register it with atexit or the framework shutdown hook. Gaps under load suggest the queue hit maxsize: raise it, shed only DEBUG and INFO, and export the dropped counter as a metric. A sink that stays empty suggests the handler level is being ignored: set respect_handler_level to True on the QueueListener. A trace_id of all zeros suggests the context filter is running on the listener thread: move it onto the QueueHandler, on the producing side where contextvars still exist. symptom → what to check → the fix start here records missing or duplicated every line appears twice handlers attached twice? tail lost on every deploy stop() never called? gaps only under load queue hit maxsize? one sink stays empty handler level ignored? trace_id is all zeros filter on the listener? attach handlers in one place; propagate = False libraries add only NullHandler() register listener.stop() with atexit or the framework's shutdown hook raise maxsize, shed DEBUG/INFO only and export the dropped counter respect_handler_level = True on the QueueListener move the context filter to the QueueHandler the producing side, where contextvars live
Five symptoms, five checks, five fixes — the branches below spell each one out with its error signature.
  • Error signature: P99 latency tracks the health of your log backend, and flame graphs show request threads parked in socket.send or os.write inside emit. Root cause: a FileHandler, SysLogHandler, or HTTP-based handler is attached directly to the application logger, so every write happens synchronously on the request thread while holding the handler lock. Remediation: make a QueueHandler the only handler on that logger and move the concrete sinks behind a QueueListener, as in Step 5.

  • Error signature: every log line appears exactly twice (or N+1 times) in the aggregator, and derived event counts are inflated. Root cause: handlers are attached to both a child logger and the root, and the child left propagate at its default True, so callHandlers() walks the ancestor chain and emits again. Remediation: attach handlers in exactly one place — normally the root — and set propagate = False on any logger that owns handlers; libraries should attach only logging.NullHandler().

  • Error signature: structured logs show "exception": null while the traceback text is glued onto the message field, but only after a queue was introduced. Root cause: QueueHandler.prepare() formatted the record and cleared exc_info, exc_text, args, and stack_info to make it picklable, so the listener-side formatter has no exception object left to project. Remediation: with an in-process queue.Queue, override prepare() to return the record unchanged; with a multiprocessing.Queue, serialize the exception into a plain string field before enqueueing.

  • Error signature: trace_id is all zeros in production but correct in unit tests, or the formatter raises ValueError: Formatting field not found in record: 'trace_id'. Root cause: the context-reading filter was attached to a handler owned by the QueueListener, so it executes on the listener thread, which never entered the request's contextvars context. Remediation: attach context filters to the logger or to the QueueHandler — the producing side — and keep handler-side filters restricted to data already on the record.

  • Error signature: RSS climbs steadily during a collector outage and the process is OOM-killed, with no dropped-record warnings anywhere. Root cause: the queue was created without maxsize, so a stalled sink lets it grow without bound; the drop policy never ran because the queue was never full. Remediation: always pass an explicit maxsize, shed low-severity records on queue.Full, and export a counter of shed records so degradation is observable.

  • Error signature: the last few seconds of logs are missing after every deploy, and crash diagnostics are truncated exactly where they matter. Root cause: the process exited without calling listener.stop(), so records still in the queue were discarded along with the daemon listener thread — or the listener was never started at all after dictConfig. Remediation: register listener.stop() with atexit or the framework's shutdown hook, and assert listener._thread is not None in a startup self-check; see how to configure Python logging for production for the full startup checklist.

Frequently Asked Questions

How does handler architecture impact P99 latency in async Python services?

Synchronous handlers block the event loop or worker thread during I/O. Using a QueueHandler with a background QueueListener offloads serialization and writes, which preserves event loop responsiveness and stabilizes latency under load.

Should I use one handler per log sink or a single multiplexing handler?

Deploy a dedicated handler per sink with its own filter and formatter. This isolates failure domains and lets you tune backpressure per sink without cross-contamination between destinations.

How do I safely reload handler configuration without dropping logs?

Use a QueueHandler as the stable attachment point on the logger and swap the backend listener atomically. Drain the queue before replacing the concrete handlers so no buffered record is lost during the hot reload.

What happens to log records when the queue is full?

By default QueueHandler blocks the caller, which is rarely acceptable in production. Override the enqueue path with put_nowait and a drop policy that sheds debug and info records first, preserving error and critical visibility.

Why does exc_info disappear once I put a queue in front of my handlers?

QueueHandler.prepare() formats the record, copies it, and then clears args, exc_info, exc_text, and stack_info so the object is safe to pickle. Downstream formatters therefore see the traceback glued onto the message with exc_info set to None. With an in-process queue.Queue nothing is pickled, so you can override prepare() to return the record untouched.

Where should a filter live when a queue sits between the logger and the handlers?

Filters that read ambient state such as contextvars must run on the producing thread, so attach them to the logger or to the QueueHandler. Filters that only inspect data already on the record, such as severity routing, can safely live on the concrete handlers owned by the listener.