Writing a Custom Logging Handler in Python

Eventually a sink exists that no stdlib handler covers — an internal audit bus, a vendor SDK, a socket with its own framing. This page covers the four things a custom logging.Handler must get right: what belongs in emit(), why it must never raise, where the lock is, and what close() owes the interpreter. It builds on the handler architecture guide and is part of the Python logging fundamentals and structured data section.

The base class does more than it looks like it does. Most handler bugs come from re-implementing something Handler already handled, or from doing something inside emit() that was never meant to be there.

What the base class does, and the one method you own A record entering a handler passes through four stages before your code runs. First the handler's level is compared against the record level. Second the handler's filters run. Third the handler lock is acquired, which serialises every thread using this handler. Only then is emit called — the single method a subclass normally overrides. If emit raises, the base class catches it and calls handleError, which prints a diagnostic when raiseExceptions is enabled and otherwise silently discards, so a failing sink can never propagate an exception into the application code that logged. The lock is released afterwards regardless. A footnote marks that the level check, the filters, the lock and the error containment are all inherited, so a subclass that re-implements them is adding bugs rather than behaviour. Handler.handle() — four inherited stages, then yours level check inherited filters inherited acquire self.lock every thread queues here emit(record) the one method you write keep it short — the lock is held if it raises handleError(record) prints a diagnostic if raiseExceptions the application never sees the failure what a subclass should not re-implement level comparison · filter evaluation · locking · exception containment each one is already correct, and each re-implementation is a new place to be wrong the lock is the design constraint: whatever emit does, every other thread logging to this handler is waiting for it so a network round trip inside emit is not slow logging — it is a serialisation point for the whole process
Four inherited stages, one method to write. The lock in stage three is why the answer to "can I do a HTTP POST in emit" is no.

Prerequisites

Standard library only for the handler itself; the example ships records to an HTTP endpoint, so pin a client if you follow it literally.

pip install "httpx>=0.27.0,<1.0.0"
export AUDIT_SINK_URL="https://audit.internal/v1/events"
export AUDIT_BUFFER_MAX=2000

Implementation

Step 1 — Override emit() and nothing else. Format through self.format(record) so the handler's formatter, and any formatter someone sets later through dictConfig, is respected. Wrap the body so nothing escapes.

import logging

class AuditHandler(logging.Handler):
    def __init__(self, url: str, level: int = logging.NOTSET):
        super().__init__(level=level)
        self.url = url

    def emit(self, record: logging.LogRecord) -> None:
        try:
            payload = self.format(record)          # honours whatever formatter is set
            self._enqueue(payload)                 # must be cheap — see step 2
        except Exception:
            self.handleError(record)               # never propagate into caller code

handleError() is the contract. Logging is infrastructure; an audit endpoint returning 503 must not turn into a 500 for the user whose request happened to log. The base implementation prints to stderr when logging.raiseExceptions is true, which is right for development and wrong for production — set it to False and, if you need visibility, override handleError to increment a counter instead.

Step 2 — Get the I/O out of the lock. Handler.handle() holds self.lock for the whole of emit(). An HTTP call there does not just slow that one log statement; it stops every thread in the process that logs to this handler for the duration. Buffer in emit(), send from a worker.

import queue
import threading
import httpx

class AuditHandler(logging.Handler):
    def __init__(self, url: str, max_buffer: int = 2000, batch: int = 100):
        super().__init__()
        self.url = url
        self.batch = batch
        self._buffer: queue.Queue[str] = queue.Queue(maxsize=max_buffer)
        self._stopping = threading.Event()
        self._worker = threading.Thread(target=self._run, name="audit-sink", daemon=True)
        self._worker.start()

    def emit(self, record: logging.LogRecord) -> None:
        try:
            self._buffer.put_nowait(self.format(record))   # never blocks the caller
        except queue.Full:
            self.handleError(record)                       # explicit drop, counted
        except Exception:
            self.handleError(record)

    def _run(self) -> None:
        with httpx.Client(timeout=5.0) as client:
            while not (self._stopping.is_set() and self._buffer.empty()):
                batch = []
                try:
                    batch.append(self._buffer.get(timeout=0.5))
                except queue.Empty:
                    continue
                while len(batch) < self.batch:
                    try:
                        batch.append(self._buffer.get_nowait())
                    except queue.Empty:
                        break
                try:
                    client.post(self.url, json={"events": batch})
                except Exception:
                    pass                                   # a sink outage must not kill the worker

The daemon=True thread plus an explicit drain in close() is deliberate: a non-daemon worker that never exits hangs the interpreter, and a daemon worker with no drain loses the buffer. You need both halves.

Step 3 — Make close() flush and deregister. logging.shutdown() walks every handler at exit and calls flush() then close(). Your override drains, stops the worker, and then calls super().close(), which removes the handler from the internal list so shutdown does not touch it twice.

    def flush(self) -> None:
        deadline = time.monotonic() + 2.0
        while not self._buffer.empty() and time.monotonic() < deadline:
            time.sleep(0.02)

    def close(self) -> None:
        try:
            self._stopping.set()
            self._worker.join(timeout=3.0)         # bounded — never block shutdown forever
        finally:
            super().close()                        # deregisters from logging's handler list

Every wait here is bounded. An unbounded join in close() is how a process that is otherwise ready to exit sits for minutes waiting on a sink that is already gone.

Where the round trip happens decides what the lock costs Two designs for the same handler. On the left, emit performs the HTTP post directly, so the handler lock is held for the whole round trip: four worker threads that all log to this handler are serialised behind it, and each one waits for the sink's latency plus everyone ahead of it in the queue. A slow sink therefore becomes a service-wide stall rather than a logging problem. On the right, emit puts the formatted record on a bounded in-memory buffer and returns, so the lock is held for microseconds; a single background worker drains the buffer in batches and does the round trip off the request path. The sink's latency no longer appears anywhere in the request threads, and the failure mode changes from a stall to a bounded, counted drop when the buffer fills. the same sink, two places to call it post inside emit thread 1 thread 2 thread 3 thread 4 all waiting on one lock lock held format HTTP round trip 5 ms — or 30 s a slow sink becomes a service-wide stall and the stall scales with thread count failure mode: everything stops enqueue in emit, post in a worker thread 1 thread 2 thread 3 thread 4 lock held for microseconds bounded buffer maxsize 2000 one worker batches of 100 the sink's latency never reaches a request thread and a full buffer drops explicitly, and counts it failure mode: bounded, visible loss
Both handlers ship the same records to the same endpoint. Only one of them stops being a logging problem when the endpoint gets slow.

Step 4 — Wire it declaratively. A handler that can only be constructed in code cannot be reconfigured per environment. Reference it by dotted path in dictConfig, exactly as described in configuring logging with dictConfig.

CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {"json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter"}},
    "handlers": {
        "audit": {
            "()": "observability.audit.AuditHandler",
            "url": "https://audit.internal/v1/events",
            "max_buffer": 2000,
            "level": "INFO",
            "formatter": "json",
        },
    },
    "loggers": {"audit": {"level": "INFO", "handlers": ["audit"], "propagate": False}},
}
Shutdown, with every wait bounded The shutdown sequence for a handler that buffers. When the interpreter exits, logging.shutdown walks its handler list and calls flush then close on each. The bounded version drains the buffer with a deadline of about two seconds, signals the worker to stop, joins it with a three second timeout, and finally calls the base class close, which deregisters the handler. Total worst case is a few seconds, and every step completes even when the sink is unreachable. The unbounded version — a drain loop with no deadline, or a join with no timeout — waits on a sink that is already gone, so the container consumes its entire termination grace period on every deploy and is eventually killed rather than exiting. A footer notes that a non-daemon worker thread produces the same symptom even when close is never reached. logging.shutdown() calls flush() then close() on every handler bounded — every wait has a deadline drain the buffer · deadline 2 s stop event set · join(timeout=3) super().close() · deregisters the handler worst case a few seconds, sink up or down unbounded — one missing timeout drain until the buffer is empty join() with no timeout — waits on a dead sink the container burns its whole grace period then is killed instead of exiting — every deploy a non-daemon worker produces the same symptom without close() being involved at all: the interpreter will not exit while it runs so the safe shape is daemon worker + bounded drain + bounded join, all three together
Every wait in this path needs a number on it. The failure mode of a missing one is not lost logs — it is a service that takes its full termination grace period to stop.

Configuration options

Option Type Default Recommended
max_buffer int 1 000–5 000, always bounded
batch int 1 50–200 records per request
worker daemon bool False True, plus a bounded drain in close()
join(timeout=…) float none 3 s — never unbounded at shutdown
logging.raiseExceptions bool True False in production
propagate bool True False on the dedicated logger

Verification

Test the two properties that distinguish a safe handler from a dangerous one: it never raises, and it never blocks.

import logging, time

def test_emit_never_raises():
    h = AuditHandler(url="http://127.0.0.1:1")     # nothing listening
    h.setFormatter(logging.Formatter("%(message)s"))
    record = logging.LogRecord("t", logging.INFO, "", 0, "hello", (), None)
    h.emit(record)                                  # must not raise
    h.close()

def test_emit_is_fast_even_when_the_sink_is_down():
    h = AuditHandler(url="http://127.0.0.1:1")
    h.setFormatter(logging.Formatter("%(message)s"))
    record = logging.LogRecord("t", logging.INFO, "", 0, "hello", (), None)
    start = time.perf_counter()
    for _ in range(1000):
        h.emit(record)
    assert time.perf_counter() - start < 0.1        # enqueue only, no round trips
    h.close()

Expected Output:

test_handler.py::test_emit_never_raises PASSED
test_handler.py::test_emit_is_fast_even_when_the_sink_is_down PASSED

Common mistakes

The handler blocks every thread in the process

Error signature: request latency across unrelated endpoints tracks the log sink's health. Root cause: emit() performs network I/O while holding the handler lock. Remediation: buffer in emit(), send from a worker — or keep the handler simple and put a QueueHandler in front of it, as in non-blocking logging with QueueHandler.

An exception from the sink reaches application code

Error signature: a request fails with a ConnectionError whose traceback runs through logger.info. Root cause: emit() let an exception escape instead of routing it to handleError. Remediation: wrap the whole body and call self.handleError(record). Keep logging.raiseExceptions = False in production so the fallback is silent rather than a stderr flood.

The process hangs at exit

Error signature: the container takes its full termination grace period to stop, every time. Root cause: a non-daemon worker thread, or an unbounded join() in close(), waiting on a sink that is unreachable. Remediation: daemon worker, bounded drain, bounded join, and super().close() at the end.

When not to write one

A custom handler is the right answer less often than it looks, and the alternatives are cheaper to operate. Three of them cover most cases that reach for a subclass first.

Write to stdout and let something else ship it. In a containerised deployment, a StreamHandler to stdout plus a collector agent is fewer moving parts than any in-process shipper: no buffer to size, no worker thread to shut down, no retry policy to get wrong, and a sink outage becomes the platform's problem rather than your process's. The custom handler earns its place when the destination genuinely cannot be reached that way — an internal audit bus with its own protocol, a vendor SDK with no file or stdout mode.

Use a QueueHandler in front of a simple handler. If the only reason for the subclass is to get I/O off the calling thread, the standard library already has that: a QueueHandler on the logger and the concrete handler owned by a QueueListener. That gives the background-thread behaviour without writing or maintaining the worker, and it composes with every other stdlib handler.

Use a filter or a formatter instead. A surprising share of custom handlers exist to change what a record looks like or which records are emitted, both of which belong in a formatter or a filter. Those are smaller, individually testable, and composable across sinks — a formatter written once serves every handler, while behaviour embedded in a handler subclass is available only to that one.

What you actually want Reach for Not
I/O off the calling thread QueueHandler + QueueListener a subclass with its own worker
A different output shape a Formatter a subclass that formats inline
Fewer records a Filter a subclass that drops in emit
A destination with its own protocol a custom handler a shell-out or a sidecar file
Durability across restarts a file plus an agent an in-process retry queue

If you do write one, keep the surface small

The handlers that age well share a shape: they know how to send one batch to one destination, and they know nothing about formatting, filtering, levels, or what should be logged. Everything else is composed around them.

Two habits reinforce that. First, take the destination as a constructor argument rather than reading configuration inside the class, so the handler is testable with a fake and configurable with dictConfig. Second, expose the operational state — records buffered, records dropped, last error — as attributes a metrics callback can read, rather than logging about itself. A handler that logs its own failures through the logging system it is part of will, eventually, produce a loop.

class AuditHandler(logging.Handler):
    def __init__(self, url: str, max_buffer: int = 2000):
        super().__init__()
        self.dropped = 0                 # read by a metrics callback, not logged
        self.last_error: str | None = None
        ...

    def handleError(self, record: logging.LogRecord) -> None:
        self.dropped += 1                # count, do not narrate
        self.last_error = repr(sys.exc_info()[1])

Testing the failure paths

The happy path of a custom handler is easy and rarely the problem. Three tests cover the cases that actually break production, and all three are fast because none of them needs a real sink: emit with an unreachable destination must not raise and must return promptly; close with the worker blocked must return within its timeout; and a full buffer must drop rather than grow. Writing those three first tends to produce a better handler than writing them afterwards, because each one constrains the design.

Frequently Asked Questions

Do I need to acquire a lock inside emit?

No. Handler.handle acquires self.lock around emit for you, so your emit body is already serialised against other threads using the same handler. What that means in practice is the opposite of a safety net: anything slow you do inside emit blocks every other thread that logs to this handler, so the goal is to make emit short rather than to add more locking.

Why does my handler print 'Logging error' to stderr?

Something raised inside emit and the base class caught it in handleError, which prints a diagnostic when logging.raiseExceptions is True. That is the intended behaviour — it stops a broken sink from breaking the application — but in production you want raiseExceptions set to False and your own fallback path in handleError instead.

Should a custom handler do network I/O directly?

Not on the calling thread. Put the record on a bounded in-memory buffer in emit and let a worker thread do the send, or place a QueueHandler in front and run your handler in the QueueListener. Either way the request thread pays an enqueue, not a round trip.

What has to happen in close()?

Flush whatever you buffered, stop any worker thread, release the resource, and call super().close(). The base implementation removes the handler from the internal handler list that logging.shutdown walks, and skipping it means shutdown may try to close an already-dead handler.

Can I reuse a formatter across handlers?

Yes, formatters are stateless enough to share, with one caveat: Formatter.format caches its rendered traceback on record.exc_text, and records are shared between handlers. If two handlers should render exceptions differently, each formatter must clear exc_text before rendering.