Logging Exceptions and Tracebacks in Python

You need the traceback in your log, not a message that says an error occurred — and you need it as fields your backend can group on rather than a multi-line blob. This walkthrough is for engineers wiring up a service's error path for the first time, or fixing one that lost its stack somewhere between the except block and the sink. It builds on the exception and traceback logging guide and is part of the Python logging fundamentals and structured data section.

Everything here turns on one fact: exc_info on a log record is a live (type, value, traceback) triple, not text. What you do with that triple before it reaches a formatter decides whether an incident is one queryable record or a wall of prose.

What each exc_info value produces Four forms of the exc_info argument compared. Passing True, which is what logger.exception does, picks up the exception currently being handled through sys.exc_info and is correct inside an except block but yields the literal text NoneType colon None outside one. Passing an exception instance uses that specific object and its own traceback, which is the right form when logging an exception pulled off a Future or captured earlier. Passing an explicit three-tuple of type, value and traceback is what interpreter hooks such as sys.excepthook receive and forward unchanged. Leaving exc_info at its default of False attaches nothing at all, so the record carries only the message and the stack is lost. exc_info takes four shapes — only two of them are ever what you want exc_info=True what logger.exception passes reads sys.exc_info() correct inside except outside it: NoneType: None exc_info=exc an exception instance uses that object's own __traceback__ and chain right for a Future's result exc_info=(t, v, tb) the explicit triple what sys.excepthook and threading.excepthook hand you forward it unchanged exc_info omitted — the default the record carries the message and nothing else this is what "ERROR: something went wrong" looks like from the inside stack_info=True adds the caller stack too independent of exc_info a traceback covers the frames the exception travelled through — stack_info covers how you got to the log call they answer different questions, and a re-raise at the top of the stack is why the first one sometimes looks too short
The two useful forms are True inside an except block and an exception instance everywhere else. The default is how stacks get lost.

Prerequisites

The standard library covers everything on this page. Pin a JSON serialiser only if you want the structured output shown below in a service that does not already have one.

pip install "python-json-logger>=2.0.7,<4.0.0"
export LOG_TRACEBACK_FRAMES=20     # read by the formatter below

Implementation

Step 1 — Attach the exception at the boundary that handles it. logger.exception() is logger.error() with exc_info=True; inside an except block it picks up the active exception with its traceback and its full chain. Outside one, pass the object you hold.

import logging

logger = logging.getLogger(__name__)

def load_user(row: dict) -> dict:
    try:
        return {"id": row["user_id"], "email": row["email"]}
    except KeyError as exc:
        raise ValueError("row is missing a required column") from exc   # keep the link

def handler(rows: list[dict]) -> None:
    for row in rows:
        try:
            load_user(row)
        except ValueError:
            logger.exception("row rejected", extra={"row_index": rows.index(row)})

One log call, at the layer that decides to skip the row. load_user translates instead of logging, so the record carries ValueError with the KeyError attached underneath as __cause__.

Step 2 — Slice the frame list from the innermost end. traceback.extract_tb() returns frames outermost-first, so a naive [:20] keeps twenty frames of WSGI, middleware and routing and drops the line that actually failed. Slice with [-20:] instead.

import traceback

FRAMES = 20

def frames_of(exc: BaseException) -> list[dict]:
    """Innermost FRAMES stack entries — the ones nearest the failure."""
    entries = traceback.extract_tb(exc.__traceback__)
    kept = entries[-FRAMES:]
    return [
        {"file": f.filename, "line": f.lineno, "func": f.name, "code": f.line}
        for f in kept
    ]
Which end of the stack you keep decides whether the log is useful A single forty-frame traceback drawn as a vertical strip, outermost frame at the top and the raise site at the bottom. The upper region is framework code — the WSGI server, the routing layer, middleware — identical on every request in the service. The lower region is application code ending at the line that raised. Slicing the first twenty entries keeps only the upper region, so every truncated traceback in the service looks the same and none of them names the failing line. Slicing the last twenty keeps the lower region, so the raise site and its immediate callers always survive, and the framework preamble, which was never in question, is what gets dropped. one 40-frame traceback, two ways to cut it to 20 frames 1–20 server, routing, middleware same on every request frames 21–40 your view, your service, your repository layer the raise is the last one outermost the raise site entries[:20] keeps the top block every error looks alike the failing line is gone entries[-20:] keeps the bottom block the raise site survives drop what was never in doubt and mark it "truncated": true "frames_total": 40 so nobody debugs a shortened stack believing it is whole a RecursionError is the stress test extract_tb returns outermost-first — the slice direction is the whole difference between a useful record and a generic one
A thousand-frame RecursionError is the case that proves the rule: the only frames worth keeping are the last few, and they are exactly the ones a front slice throws away.

Step 3 — Walk the chain. __cause__ is set by raise ... from exc; __context__ is set implicitly when an exception is raised inside an except block; __suppress_context__ is set by raise ... from None. Prefer cause, fall back to context, and stop at a fixed depth so a pathological chain cannot run away.

def serialise(exc: BaseException, depth: int = 0) -> dict:
    node = {
        "type": type(exc).__name__,
        "module": type(exc).__module__,
        "message": str(exc)[:512],
        "frames": frames_of(exc),
    }
    if depth >= 3:
        node["chain_truncated"] = True
        return node
    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

Keeping cause and context under different keys is what lets you alert on the second one. A context link means something failed while handling a failure — a rollback, a cleanup, a finally — and that is a distinct class of bug worth paging on separately.

Step 4 — Render fields instead of prose. Override formatException() and clear the cached exc_text first, or this formatter may be handed a rendering some other handler produced from the same shared record.

import json
import logging

class ExceptionFieldsFormatter(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                       # do not inherit another handler's render
        out = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        for key, value in getattr(record, "__dict__", {}).items():
            if key == "row_index":                   # promote your own extras explicitly
                out[key] = value
        if record.exc_info:
            out["exception"] = json.loads(self.formatException(record.exc_info))
        return json.dumps(out, default=str)

Configuration options

Option Type Default Recommended
exc_info bool / exception / triple False True in except, the object elsewhere
stack_info bool False True only when the caller path is the mystery
FRAMES slice int unlimited -20: — innermost twenty
chain depth int unlimited 3 links, then mark truncated
str(exc) cap int unlimited 512 bytes
record.exc_text str / None cached by first formatter reset to None per formatter

Verification

Run a chained failure through the formatter and check three things: the exception is its own object rather than text in the message, the chain survived, and the innermost frame is the line that raised.

import logging, sys

h = logging.StreamHandler(sys.stdout)
h.setFormatter(ExceptionFieldsFormatter())
log = logging.getLogger("verify")
log.addHandler(h)
log.setLevel(logging.INFO)

try:
    try:
        {"a": 1}["user_id"]
    except KeyError as exc:
        raise ValueError("row is missing a required column") from exc
except ValueError:
    log.exception("row rejected", extra={"row_index": 7})

Expected Output:

{"ts": "2026-08-02T11:22:03+0000", "level": "ERROR", "logger": "verify",
 "message": "row rejected", "row_index": 7,
 "exception": {"type": "ValueError", "module": "builtins",
   "message": "row is missing a required column",
   "frames": [{"file": "verify.py", "line": 16, "func": "<module>",
               "code": "raise ValueError(\"row is missing a required column\") from exc"}],
   "cause": {"type": "KeyError", "module": "builtins", "message": "'user_id'",
     "frames": [{"file": "verify.py", "line": 14, "func": "<module>",
                 "code": "{\"a\": 1}[\"user_id\"]"}]}}}

Lock it in with a test so a later refactor cannot quietly drop the chain:

def test_chain_survives(caplog):
    with caplog.at_level(logging.ERROR):
        try:
            try:
                raise KeyError("user_id")
            except KeyError as exc:
                raise ValueError("bad row") from exc
        except ValueError:
            logging.getLogger("t").exception("failed")
    record = caplog.records[0]
    data = serialise(record.exc_info[1])
    assert data["type"] == "ValueError"
    assert data["cause"]["type"] == "KeyError"      # the link is the point

Expected Output:

test_exceptions.py::test_chain_survives PASSED

Common mistakes

NoneType: None where the traceback should be

Error signature: the record ends with the literal text NoneType: None. Root cause: logger.exception() or exc_info=True was called outside an except block, so sys.exc_info() returned an empty triple. Remediation: call it only from an except block; elsewhere pass the object — logger.error("failed", exc_info=exc). Logging from a callback or a Future result almost always needs the second form.

The traceback stops at the re-raise

Error signature: one frame, pointing at your own raise statement, with nothing underneath. Root cause: the code caught an exception and raised a fresh one with a bare raise NewError(...) at the top of the stack, so the new object's traceback begins there. Remediation: write raise NewError(...) from exc. The chain link carries the original traceback, which is where the deeper frames live.

Every truncated traceback looks identical

Error signature: hundreds of distinct failures share the same twenty frames of middleware and none names an application file. Root cause: the frame list was sliced from the front, keeping the outermost frames. Remediation: slice with [-N:] and record frames_total alongside a truncated flag so the shortening is visible in the record.

The same failure, as a blob and as fields Two panels showing what a backend stores for one chained failure. On the left, the default formatter appends the rendered traceback to the message, so the backend holds a single multi-line text field: grouping falls back to fuzzy message matching, faceting by exception type is impossible, and the chained KeyError is buried in the middle of the prose. On the right, the structured formatter emits an exception object with separate type, module, message and frames keys and a nested cause object, so an alert can group on exception dot type, a dashboard can count by module, and the chain relationship is a queryable field rather than an English sentence in the middle of a string. what the backend actually stores default formatter — one text field message: "row rejected Traceback (most recent call last): File "app.py", line 14, in load_user KeyError: 'user_id' The above exception was the direct… ValueError: row is missing a column" group by: fuzzy message match, and nothing else structured formatter — fields exception.type = "ValueError" exception.frames[-1].file = "app.py" exception.cause.type = "KeyError" row_index = 7 group by: exception.type · facet by module · alert on cause same bytes on the wire, give or take — the difference is entirely in whether the backend can index them
Both records contain the same information. Only one of them can answer "how many distinct exception types fired in the last hour" without a regex.

Operating it

The structured form pays off in three specific ways once it is running, and each one is worth setting up deliberately rather than hoping the backend does it for you.

Grouping. With exception.type and the innermost frame's file and line as separate fields, a backend can group by the tuple and give you one row per distinct failure rather than one row per occurrence. That single change turns "eight thousand errors overnight" into "three distinct failures, one of which accounts for 97% of the volume", which is the first question anyone asks. Group on the type plus the innermost frame rather than on the message: the message frequently contains an identifier, so grouping by it produces one group per request and hides the pattern completely.

Alerting on the chain shape. A context link — an exception raised while another was being handled — is a distinct class of bug, and it is worth its own alert precisely because it is rare. A rollback that fails during error handling, a finally block that raises, a cleanup that assumes a resource which was never acquired: all of them surface as a context link and none of them surface as anything else. A query for records where exception.context exists, alerting on any occurrence, is one of the cheapest high-signal alerts available.

Retention. Serialised exceptions are large compared with ordinary records — a twenty-frame traceback with source lines is several kilobytes — so an error burst can cost more storage than a day of normal traffic. The frame limit, the chain depth and the message truncation from the implementation section exist for that reason, and they are worth revisiting once you can see real volume. A useful shape is to keep the full serialised exception for ERROR and above and to strip frames from anything lower, since a DEBUG record with a traceback is almost always incidental.

Concern Signal to watch Action when it moves
Grouping quality distinct groups per hour too many → grouping on the message; too few → the type is too generic
Chain shape count of records with a context link any sustained value is its own bug
Payload size bytes per error record tighten the frame limit before the retention bill does it for you
Truncation records with truncated: true consistent truncation means the limit is too low for real stacks

Custom exception types are part of the instrumentation

One design decision does more for grouping than any formatter change: raise domain-specific exception types rather than reusing ValueError everywhere. OrderRejected, PaymentDeclined and InventoryUnavailable group cleanly, alert independently and read correctly in a dashboard; three ValueErrors with different messages do none of that, and no amount of serialisation fixes it. The exception type is the field the whole error pipeline is keyed on, so it is worth choosing rather than defaulting to whatever the standard library offers.

The corollary is to keep the hierarchy shallow. A base AppError with a handful of direct subclasses gives you both a specific type for grouping and a general one for except clauses. A five-level hierarchy gives you neither, because every alert has to decide which level to key on and different people choose differently.

Frequently Asked Questions

Do I need to pass the exception to logger.exception?

No. Inside an except block, logger.exception picks up the exception currently being handled through sys.exc_info, so logger.exception('failed') is complete on its own. Pass exc_info explicitly only when you are logging an exception object you are holding — one captured earlier, or one pulled off a Future — because there is no active exception in that case.

Why is my traceback only one frame long?

A traceback records the frames the exception travelled through after it was raised, not the whole call stack that led there. If you catch and re-raise a fresh exception at the top of the stack, its traceback starts at that point. Use raise NewError from exc to keep the original, whose traceback does cover the deeper frames, or add stack_info=True to capture the caller stack as well.

Is logger.exception safe to call outside an except block?

It runs, but it emits NoneType: None where the traceback should be, because there is no active exception to pick up. Guard it: call it only from an except block, and use logger.error(msg, exc_info=exc) everywhere else.

How many stack frames should I keep in production?

Twenty is a good default, sliced from the innermost end. That reliably covers your own code plus the framework layer that called it, while capping a RecursionError at a few kilobytes instead of a thousand frames. Record a truncated marker so nobody debugs against a silently shortened stack.