Buffering Log Records with MemoryHandler

The most useful DEBUG records are the ones immediately before an error, and the most wasteful DEBUG records are all the others. logging.handlers.MemoryHandler resolves that tension: hold recent records in memory, ship them only when something goes wrong. This page covers the buffering handler, the ring-buffer variant that makes it practical, and the per-request scoping that makes it correct under concurrency. It builds on the handler architecture guide, part of the Python logging fundamentals and structured data section.

The same DEBUG records, kept only when they turn out to matter Two request timelines running left to right. The upper one is a successful request: eight DEBUG records enter the buffer as the request proceeds, no error occurs, and at the end the buffer is discarded, so the sink receives only the single INFO completion record. The lower one is a failing request: the same eight DEBUG records enter the buffer, then an ERROR record arrives, which reaches the configured flush level and drains the entire buffer into the target handler. The sink therefore receives the error together with every DEBUG record that preceded it — the state, the query, the retry — as full context for exactly the request that needed it. A footer notes that the cost of the successful case is buffer memory only: nothing was formatted, serialised, or written. two requests, the same logging calls, very different output request A · succeeds 8 DEBUG records buffered… buffer discarded the sink receives: one INFO completion record request B · fails ERROR — reaches flushLevel buffer flushed into the target handler the sink receives: 8 DEBUG records + the ERROR — full context for the one request that needed it what request A cost buffer memory, and nothing else — no formatting, no serialisation, no write, no storage, no retention which is why this pattern lets you leave DEBUG on in production without paying for DEBUG in production
The trade is memory for storage. A thousand buffered records per process is about a megabyte; a thousand shipped records per request is a log bill.

Prerequisites

Standard library only.

export LOG_BUFFER_CAPACITY=500     # records held per buffer
export LOG_BUFFER_FLUSH_LEVEL=ERROR

Implementation

Step 1 — Wrap the real handler. MemoryHandler(capacity, flushLevel, target) accumulates records and replays them into target on flush. The target keeps its own formatter and level, so the replayed records look exactly like normal output.

import logging
import logging.handlers

target = logging.StreamHandler()                       # the real sink
target.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
target.setLevel(logging.DEBUG)                         # must accept what the buffer replays

buffer = logging.handlers.MemoryHandler(
    capacity=500,
    flushLevel=logging.ERROR,
    target=target,
)

root = logging.getLogger()
root.setLevel(logging.DEBUG)                           # DEBUG must reach the buffer
root.addHandler(buffer)

Note the level split. The logger is at DEBUG so records are created; the buffer holds them; the target must be at DEBUG too, or it will discard the very records the flush was meant to deliver. If you also want ordinary INFO traffic emitted immediately, add a second handler at INFO directly on the logger rather than raising the target's level.

Step 2 — Turn it into a ring buffer. The stock shouldFlush returns True when the buffer is full, which means a quiet service periodically dumps five hundred DEBUG records for no reason. Override it to flush on level only, and discard the oldest record when full.

class RingMemoryHandler(logging.handlers.MemoryHandler):
    """Flush on level only; when full, drop the oldest record instead of flushing."""

    def shouldFlush(self, record: logging.LogRecord) -> bool:
        return record.levelno >= self.flushLevel

    def emit(self, record: logging.LogRecord) -> None:
        if len(self.buffer) >= self.capacity:
            del self.buffer[0]                         # oldest out, newest in
        super().emit(record)

That is the whole difference between "a batching handler" and "an incident context buffer". With it, a healthy process holds a rolling window of the last five hundred records and writes nothing.

Step 3 — Scope the buffer to a request. A single process-wide buffer under concurrency interleaves every in-flight request, so a flush ships eight other requests' DEBUG records alongside the failing one. In an async or threaded service, create the buffer per request and attach it to a request-scoped logger.

from contextlib import contextmanager

@contextmanager
def request_log_buffer(target: logging.Handler, capacity: int = 200):
    handler = RingMemoryHandler(capacity=capacity, flushLevel=logging.ERROR, target=target)
    logger = logging.getLogger("request")
    logger.addHandler(handler)
    try:
        yield logger
    finally:
        handler.close()                                # flushes if anything triggered it
        logger.removeHandler(handler)                  # never leak handlers per request

removeHandler in a finally is not optional. A handler added per request and never removed is one of the classic slow leaks in a Python service: memory grows, and every log record is handled once more for each request the process has ever served.

Why the buffer has to be request-scoped Two configurations under three concurrent requests. On the left a single process-wide buffer receives records from all three requests as they interleave, so when request B fails the flush ships a mixed sequence containing A's and C's records too: the incident context is diluted, unrelated requests leak into it, and the ordering suggests causal relationships that do not exist. On the right each request has its own buffer created at entry and closed at exit, so B's flush contains exactly B's records in order, while A and C discard theirs untouched. A footer warns that a per-request handler must be removed in a finally block, because a handler added and never removed accumulates on the logger and is re-invoked for every later record. three concurrent requests, one of them fails one shared buffer A · C · A · B · C · C · A · B · B(error) the flush ships all nine records A and C are in B's incident context and the interleaving implies an order that means nothing worse at higher concurrency, always one buffer per request A · A · A B · B · B(err) C · C · C only B flushes, and only B's records A and C are discarded untouched the context reads as one request, because it is one request costs one small handler per request remove the per-request handler in a finally block — a handler added and never removed is re-invoked for every later record and grows the logger's handler list for the life of the process
The shared-buffer version works perfectly in development, where concurrency is one. It degrades in exact proportion to production traffic.

Step 4 — Guard what the buffer keeps alive. A held record keeps its args alive, and exc_info keeps every frame and every frame's locals alive. A five-hundred-record buffer holding tracebacks can pin far more memory than the record count suggests. Either keep exc_info out of buffered DEBUG records, or format them on the way in.

A buffered record is a reference, not a copy A single buffered LogRecord drawn with everything it keeps alive. The record's own attributes are small — a message string, a level, a timestamp, a logger name — roughly a kilobyte in total. But the args tuple holds references to the objects passed to the log call, so a record logged with a large payload keeps that payload alive for as long as the buffer holds the record. And exc_info holds a traceback, which references every stack frame, each of which references its own local variables, so one buffered exception can pin an entire request's working set. Multiplying by a five hundred record buffer turns a nominal half megabyte into something unbounded. The remedy shown is to format expensive arguments at buffer time, or to keep exc_info out of buffered DEBUG records entirely. what one buffered record actually holds the record msg · levelname · created name · lineno · funcName about 1 KB 500 of these ≈ half a megabyte — the easy part record.args references to the objects you passed a 4 MB payload stays alive with it record.exc_info traceback → frames → frame locals one record can pin a whole request two remedies 1 · format expensive arguments on the way into the buffer 2 · keep exc_info out of buffered DEBUG records then the count really is the cost a buffer sized by record count is only meaningful when the records do not hold references — otherwise the cap is nominal
Capacity is measured in records, but memory is measured in whatever those records point at. The two are only the same if you make them the same.

Configuration options

Option Type Default Recommended
capacity int 200 per request, 500–2 000 per process
flushLevel int ERROR ERROR
target level int inherits DEBUG — it must accept the replay
shouldFlush method full or level override to level only
Buffer scope process per request under concurrency
Handler removal manual in a finally block, always

Verification

Assert the two behaviours: nothing is written on the happy path, and everything is written on the error path.

import io, logging

def test_no_output_without_an_error():
    stream = io.StringIO()
    target = logging.StreamHandler(stream)
    target.setLevel(logging.DEBUG)
    handler = RingMemoryHandler(capacity=10, flushLevel=logging.ERROR, target=target)
    log = logging.getLogger("t1"); log.addHandler(handler); log.setLevel(logging.DEBUG)

    for i in range(5):
        log.debug("step %d", i)
    assert stream.getvalue() == ""                  # buffered, not written

def test_error_flushes_the_context():
    stream = io.StringIO()
    target = logging.StreamHandler(stream)
    target.setLevel(logging.DEBUG)
    handler = RingMemoryHandler(capacity=10, flushLevel=logging.ERROR, target=target)
    log = logging.getLogger("t2"); log.addHandler(handler); log.setLevel(logging.DEBUG)

    for i in range(5):
        log.debug("step %d", i)
    log.error("failed")
    assert stream.getvalue().count("step") == 5     # the whole run-up shipped

Expected Output:

test_memory.py::test_no_output_without_an_error PASSED
test_memory.py::test_error_flushes_the_context PASSED

Common mistakes

The buffer flushes on its own

Error signature: batches of DEBUG records appear in the log at regular intervals with no error near them. Root cause: the stock shouldFlush also returns True when the buffer reaches capacity. Remediation: override shouldFlush to test the level only, and drop the oldest record when full.

The flush produces nothing

Error signature: an error record appears with no preceding context, although DEBUG calls definitely ran. Root cause: the target handler's level is INFO, so every replayed DEBUG record is discarded on arrival. Remediation: set the target to DEBUG and control ordinary volume with a second handler rather than with the target's level.

Handlers accumulate on a logger

Error signature: memory grows steadily and each log line is written many times over. Root cause: a per-request addHandler with no matching removeHandler. Remediation: use a context manager with removeHandler in finally, and assert the handler count in a test that runs many requests.

What this pattern replaces

The error-context buffer competes with two other approaches to the same problem, and the comparison is worth making explicitly because they have very different operating costs.

Logging everything at DEBUG and filtering at the backend. This works, and it is expensive in exactly the way the buffer avoids: every record is formatted, serialised, shipped, ingested and stored, and the filtering happens after all of that has been paid for. It has one genuine advantage — the records exist even for requests that succeeded, so a question asked afterwards about a successful request can still be answered. If that matters, this is the right choice and the buffer is not.

Turning DEBUG on temporarily during an incident. Covered in changing Python log levels at runtime, and it has the opposite trade: nothing is stored until you ask, and what you get starts from the moment you asked. For an ongoing problem that reproduces, this is ideal. For an intermittent failure that has already happened, it is useless, because the interesting request finished before anyone typed the command.

The buffer sits between them: nothing is stored for successful requests, and the detail for a failing one already exists at the moment it fails. The cost is memory and a small amount of complexity, and the limitation is that a request has to fail for its context to appear — a request that succeeded slowly leaves nothing behind.

Approach Cost when nothing is wrong Detail available for a past failure Detail for a slow success
DEBUG to the backend full ingestion and retention complete complete
Runtime level change none none none
Error-context buffer buffer memory only complete none
Buffer + slow-request trigger buffer memory only complete complete

That last row is a small extension worth knowing about: flush on a condition other than level. Since shouldFlush receives the record, it can trigger on anything the record carries — a duration attribute above a threshold, a specific error code, a tenant under investigation.

class ConditionalMemoryHandler(logging.handlers.MemoryHandler):
    """Flush on an error, or on any record marking a request slower than the SLO."""

    def shouldFlush(self, record: logging.LogRecord) -> bool:
        if record.levelno >= self.flushLevel:
            return True
        return getattr(record, "duration_ms", 0) > 1000          # a slow success, kept

Operating it

Three things are worth watching once this is running. The first is the flush rate: buffers flushing on a large share of requests means the error rate is high enough that the pattern is not saving anything, and the level threshold or the error rate itself needs attention. The second is memory, for the reasons in the figure above — capacity is measured in records and cost is measured in what they reference. The third is handler count on the logger, which is the leak this pattern is most prone to and the easiest to assert on.

def test_handlers_do_not_accumulate():
    logger = logging.getLogger("request")
    before = len(logger.handlers)
    for _ in range(1000):
        with request_log_buffer(target):
            pass
    assert len(logger.handlers) == before        # every one removed in its finally

A thousand iterations is enough to make the failure obvious: without the removeHandler, the count is a thousand higher and every subsequent log record is handled a thousand times.

Frequently Asked Questions

Does MemoryHandler flush when the buffer is full?

Yes, by default — shouldFlush returns True when the record count reaches capacity or the record's level reaches flushLevel. That default makes it a batching handler. For the error-context pattern you want the opposite: override shouldFlush to trigger on level only, and drop the oldest record when the buffer is full, so a quiet service does not periodically dump its whole DEBUG history.

How much memory does the buffer cost?

A LogRecord with a short message is on the order of a kilobyte once its attributes and the interned strings are counted, so a thousand-record buffer per process is roughly a megabyte. What blows that up is holding references: a record whose args include a large object keeps that object alive for as long as the buffer does, which is a real risk with exc_info.

Can I use one buffer per request instead of one per process?

Yes, and in an async service you usually should. Create the MemoryHandler per request, attach it to a request-scoped logger, and discard it at the end — otherwise concurrent requests interleave in one buffer and a flush ships everyone's context, not just the failing request's.

Does the buffer survive a crash?

Only if something flushes it. MemoryHandler.close calls flush, and logging.shutdown calls close on every handler at normal interpreter exit, so an ordinary crash through sys.excepthook still drains. A SIGKILL, an OOM kill, or a segfault does not — the buffer is process memory and it goes with the process.