Exception and Traceback Logging in Python

An exception that reaches your log as ERROR: something went wrong costs an hour of an on-call engineer's night. This guide is for backend engineers and SREs who want tracebacks that arrive complete, grouped, and queryable — and who have been burned by a formatter that swallowed the stack, a queue that stripped it, or a message field that leaked a connection string. It is part of the Python logging fundamentals and structured data section, and it assumes you already have structured logging with the standard library in place.

Three mechanics explain almost every exception-logging bug in Python: the record carries a live (type, value, traceback) triple rather than a string, the first formatter to touch it caches the rendered result for everyone else, and anything that copies or pickles the record throws that triple away. Get those three straight and the rest — chained exceptions, async escape routes, frame limits, redaction — falls into place. The concrete walkthroughs live in logging exceptions and tracebacks, capturing unhandled exceptions and warnings, and redacting sensitive data in log records.

One call, one traceback render, shared by every handler An except block calls logger.exception, which builds a LogRecord carrying exc_info as a live triple of exception type, exception value and traceback object, with exc_text still unset. The record fans out to three handlers: a stdout handler with a JSON formatter, a file handler with a text formatter, and an OTLP bridge. The first handler to format the record renders the traceback and stores the resulting string on the record as exc_text. Every later formatter finds that attribute already set and reuses it rather than rendering its own, so a per-handler exception format is silently ignored unless the formatter clears the cache first. one call, one traceback render — and every handler after the first inherits it except ValueError: logger.exception(...) the traceback is still live — nothing has been rendered to a string yet LogRecord exc_info = (type, value, tb) exc_text = None one record, three destinations stdout · JSON formatter renders — and caches the result file · text formatter reuses the cached string OTLP bridge reuses the cached string sets record.exc_text on the shared record so a per-handler exception format is silently ignored — clear exc_text at the top of formatException to opt out
The dashed arrow is the surprise: formatting is a write. The first handler mutates the record every other handler is about to read.

Prerequisites

Nothing beyond the standard library is required for capture; the optional pins below cover structured output and the OpenTelemetry correlation described later.

pip install "python-json-logger>=2.0.7,<4.0.0" \
            "opentelemetry-sdk>=1.27.0,<2.0.0"

Set the two environment variables that change how much a traceback costs you at volume. The frame limit is enforced in your own formatter — Python has no global knob for it — so treat these as the values your formatter reads.

export LOG_TRACEBACK_FRAMES=20      # frames kept per exception, innermost first
export LOG_TRACEBACK_MAX_BYTES=8192 # hard cap on the serialised exception object

If your handlers already sit behind a queue, read non-blocking logging with QueueHandler first: the queue boundary is where tracebacks most often disappear, and the fix belongs in prepare() rather than in the formatter.

Concept and architecture

A Python log record does not contain a traceback. It contains exc_info, a three-element tuple of the exception class, the exception instance, and a traceback object — all live references into the interpreter's state. logging.Formatter.format() is what turns that into text, by calling formatException(), which by default defers to traceback.print_exception().

The consequence that catches people is the caching. Formatter.format() runs roughly this sequence: render the message, then, if record.exc_info is set and record.exc_text is not, call formatException() and store the result on the record. Records are shared objects — a logger hands the same record to every matching handler — so whichever handler runs first decides what every subsequent formatter sees. A JSON handler that emits a structured exception object and a file handler that wants a classic indented traceback cannot both get their way unless one of them clears the cache.

The second structural fact is that exc_info does not survive serialisation. Traceback objects cannot be pickled. Anything that needs to move a record between processes — QueueHandler with a multiprocessing.Queue, SocketHandler, a custom fan-out that deep-copies — must render the traceback to text first and drop the triple. QueueHandler.prepare() does exactly that, which is why a traceback arrives glued onto the end of the message with exc_info set to None on the other side.

The third is the chain. Since Python 3, every exception raised while another is being handled carries a link to it: __context__ when it happens implicitly, __cause__ when you write raise NewError from exc, and __suppress_context__ when you write from None. The default traceback renderer walks those links and prints all of them, joined by one of two sentences that mean quite different things.

__cause__ versus __context__, and what each one prints Two panels comparing exception chaining. On the left, raise ValueError from exc inside an except block sets the new exception's dunder cause attribute to the original, and the rendered traceback joins the two with the sentence: the above exception was the direct cause of the following exception. This is a deliberate translation of one error into another. On the right, raising a new exception inside an except block without a from clause sets dunder context instead, and the traceback joins them with: during handling of the above exception, another exception occurred. That usually means a second, accidental failure inside the error path — a cleanup that itself broke. A footer notes that raise from None sets suppress context, hiding the earlier exception entirely, and that a serialiser which only records the outermost exception discards whichever link is present. the same two exceptions, two very different stories raise ValueError(...) from exc deliberate — you translated the error KeyError: 'user_id' The above exception was the direct cause of the following exception: ValueError: malformed payload err.__cause__ is the KeyError group on the outer type — the inner one is the detail that explains it raise ValueError(...) # no from accidental — the error path itself broke KeyError: 'user_id' During handling of the above exception, another exception occurred: ValueError: malformed payload err.__context__ is the KeyError alert on this shape — two unrelated failures in one request is its own bug raise … from None sets __suppress_context__ and hides the earlier exception entirely — useful at an API edge, lossy in a log a serialiser that records only the outermost exception throws away whichever of these links was present
"Direct cause" is a design decision you made. "During handling" is usually a second bug hiding inside the first one's cleanup path — worth its own alert.

Step-by-step implementation

Step 1 — Log the exception where you handle it, once. logger.exception() is logger.error() with exc_info=True; both attach the exception currently being handled. Call it at the boundary that decides what happens next — the request handler, the task wrapper, the retry loop — not at every frame on the way up. When you hold a specific exception object rather than the active one, pass it directly: exc_info accepts an exception instance as well as True.

import logging

logger = logging.getLogger(__name__)

def handle_order(payload: dict) -> None:
    try:
        process(payload)
    except ValueError as exc:                       # translate, keep the link
        raise OrderRejected("payload failed validation") from exc

def request_boundary(payload: dict) -> int:
    try:
        handle_order(payload)
    except OrderRejected:
        logger.exception("order rejected", extra={"order_id": payload.get("id")})
        return 400
    except Exception as exc:                        # unexpected: log the object we hold
        logger.error("order failed", exc_info=exc, extra={"order_id": payload.get("id")})
        return 500
    return 202

Both except arms produce a record carrying the full chain — OrderRejected with its __cause__ pointing at the original ValueError — because the chain lives on the exception object, not on the log call.

Step 2 — Turn the traceback into fields. The default rendering is one multi-line string, which a log backend stores as an opaque blob: you cannot facet by exception type, group by the raising module, or alert on a specific frame. Override formatException() to return a serialised object instead, and clear record.exc_text first so this formatter is not handed some other handler's rendering.

import json
import logging
import traceback

MAX_FRAMES = 20

def _serialise(exc: BaseException, depth: int = 0) -> dict:
    """One exception as fields; recurse into the chain, innermost link last."""
    frames = traceback.extract_tb(exc.__traceback__)[-MAX_FRAMES:]
    node = {
        "type": type(exc).__name__,
        "module": type(exc).__module__,
        "message": str(exc)[:512],
        "frames": [
            {"file": f.filename, "line": f.lineno, "func": f.name, "code": f.line}
            for f in frames
        ],
    }
    if depth < 3:                                   # bound the chain, not just the frames
        if exc.__cause__ is not None:
            node["cause"] = _serialise(exc.__cause__, depth + 1)
        elif exc.__context__ is not None and not exc.__suppress_context__:
            node["context"] = _serialise(exc.__context__, depth + 1)
    return node

class StructuredExceptionFormatter(logging.Formatter):
    def formatException(self, ei) -> str:
        return json.dumps(_serialise(ei[1]), default=str)

    def format(self, record: logging.LogRecord) -> str:
        record.exc_text = None                      # never inherit another handler's render
        payload = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            payload["exception"] = json.loads(self.formatException(record.exc_info))
        return json.dumps(payload, default=str)

Two details earn their keep. extract_tb(...)[-MAX_FRAMES:] keeps the innermost frames, which is where the error actually happened — slicing from the front gives you twenty frames of framework middleware and none of your code. And str(exc)[:512] bounds the message, because a ValidationError from a large payload will happily hand you fifty kilobytes of prose.

Step 3 — Distinguish the two chain relationships when you serialise. The code above records cause and context under different keys deliberately. A cause link is a design decision and belongs in the grouping key: alert on OrderRejected, and read the KeyError underneath as detail. A context link means a second exception fired while the first was being handled, which is nearly always its own bug — a finally block that raised, a rollback that failed. Giving them different key names lets you write that alert.

Step 4 — Bound the payload. A RecursionError carries a thousand frames; a deeply nested chain multiplies that. The frame slice caps one exception, depth < 3 caps the chain, and a final size check caps the whole thing so a single record cannot fill a bounded queue on its own.

def _bounded(payload: dict, limit: int = 8192) -> str:
    body = json.dumps(payload, default=str)
    if len(body) <= limit:
        return body
    exc = payload.get("exception", {})
    exc["frames"] = exc.get("frames", [])[-3:]      # keep the innermost three
    exc["truncated"] = True
    return json.dumps(payload, default=str)[:limit]

Step 5 — Close the escape hatches. Everything above assumes an except block ran. Exceptions that never reach one — a thread that dies, a task nobody awaited, a SystemExit in a worker — leave nothing in the log unless you install the interpreter's hooks. That plumbing is the subject of capturing unhandled exceptions and warnings; the short version is four hooks, and each one covers a different escape route.

Step 6 — Redact on the producing thread. Exception messages carry whatever was in the failing call: a DSN with a password, a token in a URL, a row of customer data. Attach the redaction filter to the logger rather than to a handler, so it runs before the record is queued, copied, or shipped. The pattern set and its failure modes are in redacting sensitive data in log records.

Configuration reference

Setting Where it lives Default Production value
exc_info logger.error(...) argument False True inside except, or the exception object
stack_info logger.* argument False True only for "how did we get here" puzzles
record.exc_text cached on the record set by first formatter clear it in format() on every JSON handler
Frame limit your formatException unlimited 20, sliced from the innermost end
Chain depth your serialiser unlimited 3 links
Message truncation your serialiser unlimited 512 bytes per exception
Record size cap your serialiser unlimited 8 KB, with a truncated marker
logging.raiseExceptions module global True False in production, so a broken sink cannot spam stderr
QueueHandler.prepare handler override strips exc_info return the record unchanged for in-process queues

Async and concurrency considerations

contextvars and asyncio change where an exception surfaces, not how it is formatted — and "where" is what decides whether anything logs it at all.

An exception inside a coroutine you await propagates normally into your except block. An exception inside a task you created and never awaited does not: it is stored on the task, and only when that task is garbage-collected does asyncio complain, through the loop's exception handler, often long after the request finished and with no request context left in contextvars. Set a loop exception handler at startup and the record at least reaches your sinks:

import asyncio
import logging

logger = logging.getLogger("asyncio.unhandled")

def _handler(loop, context):
    exc = context.get("exception")
    logger.error(context.get("message", "unhandled asyncio error"),
                 exc_info=exc if exc else None,
                 extra={"future": repr(context.get("future"))})

asyncio.get_event_loop().set_exception_handler(_handler)

Threads are separate again: a Thread whose target raises does not touch sys.excepthook, it goes to threading.excepthook. Worker processes started by multiprocessing inherit neither hook reliably on the spawn start method, so the child must install them itself in its initialiser. The matrix below is the one worth pinning above the desk.

Which hook catches an exception that escapes Four rows, each an escape route with the hook that catches it and the trap that comes with it. An exception on the main thread reaches sys.excepthook, and the trap is that logging.shutdown may already have run, so a buffered handler can drop the final record. A thread target that raises reaches threading.excepthook, not sys.excepthook, so a main-thread-only hook silently misses every worker thread. A task created with create_task and never awaited reaches the asyncio loop exception handler, but only when the task is garbage collected, which can be long after the request ended and with request context already gone. A subprocess started with the spawn method inherits neither hook, so the child must install both inside its own initialiser before it does any work. no except block ran — so who logs it? where it escapes the hook that catches it the trap main thread, top level the ordinary crash sys.excepthook install it at import time shutdown may already have flushed the handlers Thread(target=…) raises the worker just disappears threading.excepthook a separate hook entirely sys.excepthook never fires for a thread create_task, never awaited fire-and-forget work loop exception handler set_exception_handler fires at collection time — context is long gone spawned subprocess multiprocessing worker both, in the child set them in the initialiser spawn inherits neither hook from the parent
Four routes, four hooks. A service that installs only sys.excepthook is blind to every thread, every abandoned task, and every worker process it starts.

The queue boundary deserves its own warning. QueueHandler.prepare() calls self.format(record), assigns the result to record.msg, and then sets exc_info, exc_text, args and stack_info to None so the object can be pickled onto a multiprocessing.Queue. Downstream formatters therefore see a message with a traceback already stuck to it and no exception to format. If your queue is an in-process queue.Queue, nothing is ever pickled and you can keep the live triple:

from logging.handlers import QueueHandler

class LiveQueueHandler(QueueHandler):
    """In-process queue: nothing is pickled, so keep exc_info intact."""
    def prepare(self, record):
        return record                                # no format, no copy, no strip

That is safe only when producer and consumer share a heap. Across processes, keep the default and let prepare() render — then make sure the producing side owns the formatter you actually want, because that is the render that survives.

Production code examples

A complete service-side setup: structured exceptions, redaction on the producing thread, trace correlation, and the four hooks. The trace fields come from the active span, which is what makes an error in the log clickable through to the trace described in adding trace IDs to log records.

# observability/errors.py — import once at startup
import logging
import logging.config
import re
import sys
import threading
import warnings

SECRET = re.compile(
    r"(password|passwd|token|api[_-]?key|secret|authorization)\s*[=:]\s*[^\s,;)'\"]+",
    re.I,
)

class RedactFilter(logging.Filter):
    """Runs on the producing thread, before any queue or sink sees the record."""
    def filter(self, record: logging.LogRecord) -> bool:
        if isinstance(record.msg, str):
            record.msg = SECRET.sub(r"\1=***", record.msg)
        if record.exc_info and record.exc_info[1] is not None:
            exc = record.exc_info[1]
            if exc.args and isinstance(exc.args[0], str):
                exc.args = (SECRET.sub(r"\1=***", exc.args[0]),) + exc.args[1:]
        return True

def install_hooks(logger: logging.Logger) -> None:
    def _sys_hook(exc_type, exc, tb):
        if issubclass(exc_type, KeyboardInterrupt):      # let Ctrl-C stay quiet
            sys.__excepthook__(exc_type, exc, tb)
            return
        logger.critical("uncaught exception", exc_info=(exc_type, exc, tb))

    def _thread_hook(args):
        logger.critical("uncaught exception in thread %s", args.thread.name,
                        exc_info=(args.exc_type, args.exc_value, args.exc_traceback))

    sys.excepthook = _sys_hook
    threading.excepthook = _thread_hook
    logging.captureWarnings(True)                        # DeprecationWarning -> py.warnings
    warnings.simplefilter("default")
    logging.raiseExceptions = False                      # a broken sink must not spam stderr

Wire it with dictConfig so the filter is attached at the logger, above the handlers — the placement that guarantees redaction happens before the record is queued. The full-graph version of this configuration is in configuring logging with dictConfig.

CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {"redact": {"()": "observability.errors.RedactFilter"}},
    "formatters": {
        "json": {"()": "observability.errors.StructuredExceptionFormatter"},
    },
    "handlers": {
        "stdout": {"class": "logging.StreamHandler", "formatter": "json"},
    },
    "root": {"level": "INFO", "handlers": ["stdout"], "filters": ["redact"]},
}

Driving it with a chained failure produces one record that a backend can group by outer type and drill into by inner type:

logging.config.dictConfig(CONFIG)
install_hooks(logging.getLogger("service"))

try:
    try:
        {"a": 1}["user_id"]
    except KeyError as exc:
        raise ValueError("malformed payload; token=abc123") from exc
except ValueError:
    logging.getLogger("service").exception("request failed")

Expected Output:

{
  "ts": "2026-08-02T11:04:19+0000",
  "level": "ERROR",
  "logger": "service",
  "message": "request failed",
  "exception": {
    "type": "ValueError",
    "module": "builtins",
    "message": "malformed payload; token=***",
    "frames": [
      {"file": "app.py", "line": 42, "func": "<module>", "code": "raise ValueError(...) from exc"}
    ],
    "cause": {
      "type": "KeyError",
      "module": "builtins",
      "message": "'user_id'",
      "frames": [
        {"file": "app.py", "line": 40, "func": "<module>", "code": "{\"a\": 1}[\"user_id\"]"}
      ]
    }
  }
}

The token is masked, the chain survives as a nested object rather than a wall of text, and exception.type is a field you can group an alert on.

Common mistakes

The traceback arrives as one opaque string

Error signature: the log backend shows message containing the whole traceback, and faceting by exception type is impossible. Root cause: the default Formatter appends formatException() output to the formatted message; nothing splits it into fields. Remediation: override formatException() to return serialised fields, and emit them under their own key as in Step 2. Group alerts on exception.type, not on message text.

A second handler prints the first handler's format

Error signature: the plain-text file sink contains JSON tracebacks, or vice versa. Root cause: Formatter.format() caches its render on record.exc_text and every later formatter reuses it — records are shared across handlers. Remediation: set record.exc_text = None at the top of each formatter that renders exceptions itself. If two handlers genuinely need different renderings, that reset is mandatory, not optional.

exc_info is None on the far side of a queue

Error signature: the listener's formatter emits "exception": null while the message field contains a traceback. Root cause: QueueHandler.prepare() formats, copies, and strips the record so it can be pickled. Remediation: for an in-process queue.Queue, override prepare() to return the record unchanged. Across processes, accept the strip and put the formatter you want on the producing side.

Logging the same exception at four levels of the stack

Error signature: one incident produces four ERROR records with four different messages and four partial tracebacks. Root cause: every layer catches, logs, and re-raises. Remediation: catch to translateraise NewError from exc — and log once, at the boundary that decides the outcome. The chain preserves everything the intermediate logs were trying to say.

A traceback keeps a request alive in memory

Error signature: memory climbs after a burst of errors and does not come back down; heap dumps show request payloads pinned by frame objects. Root cause: a traceback references every frame, each frame references its locals, and the code stashed exc_info or the exception object in a module-level list for later reporting. Remediation: format and release inside the except block. If you must keep the exception, strip it first with exc.with_traceback(None) and keep only the serialised form.

Frequently Asked Questions

What is the difference between logger.exception and logger.error(exc_info=True)?

Nothing, except the level. logger.exception is a thin wrapper that calls logger.error with exc_info set to True, so both produce an ERROR record carrying the active exception. Use logger.exception inside an except block for readability, and logger.error(exc_info=exc) when you need to log a specific exception object you are holding rather than the one currently being handled.

Why does my second handler print the same traceback format as the first?

Formatter.format caches its rendered traceback on the record as exc_text and every later formatter reuses that string instead of rendering its own. If a JSON handler formats the record first, a plain-text file handler downstream will embed the JSON formatter's output. Clear record.exc_text at the start of your formatException override, or give each handler its own copy of the record.

How do I keep the traceback when a QueueHandler sits in front of my handlers?

QueueHandler.prepare renders the message, copies the record, and then clears args, exc_info and exc_text so the object is safe to pickle. With an in-process queue nothing is pickled, so you can override prepare to return the record unchanged and keep the live traceback for the listener's formatters.

Should I log the exception at the point it is raised or where it is handled?

Where it is handled, once. Logging at every level of the call stack produces one incident spread over five records with five different messages, none of which is the whole story. Let the exception propagate, log it at the boundary that decides what to do about it, and use the chain links to see where it started.

How do I stop a traceback from leaking secrets?

Exception messages routinely contain connection strings, tokens, and row data, and frame locals contain more. Run a redaction filter on the producing thread that masks known key patterns in the message and in any frame data you serialise, and never serialise frame locals wholesale in production.

Does capturing a traceback keep objects alive?

Yes. A traceback object references every frame, and each frame references its locals, so holding exc_info in a variable beyond the except block can pin large objects in memory. Format it, log it, and let it go; do not stash exception objects in a list for later reporting without stripping the traceback first.