OSPython.035: Logging Exceptions — logging.exception(), exc_info, Tracebacks, and stack_info

A diverse software engineering team studying Python error logs and tracebacks on multiple monitors.

Production software does not fail in front of the developer who wrote it. It fails later, on another machine, under another workload. Exception logging is how you preserve enough evidence to understand what happened after the moment has passed.

This lesson continues directly from OSPython.034: Contextual Logging with LoggerAdapter and extra and OSPython.033: Logging Filters. You already know how to create useful records, add context, format them, filter them, and send them to handlers. Now the goal is to make failure records useful enough to reconstruct the path to an exception.

Learning objectives

  • Use logger.exception() correctly inside an exception handler.
  • Understand how exc_info=True adds traceback information to ordinary logging calls.
  • Distinguish exception traceback data from stack_info=True.
  • Choose when to log, re-raise, translate, or suppress an exception.
  • Avoid duplicate tracebacks when several layers handle the same failure.
  • Keep passwords, tokens, secrets, and sensitive payloads out of logs.

Why the message alone is not enough

Consider an application that records only this message:

ERROR: Failed to process order

The message tells you that something failed, but not where the failure originated, which function called it, which line raised the exception, or what exception type Python produced. A traceback preserves that execution history.

The Python logging documentation defines Logger.exception() as an ERROR-level log call that automatically adds the active exception information. The documentation also makes an important restriction explicit: call it from an exception handler, where Python has an active exception to report.

Curtis Maloney, presenter of A Guided Tour of Python Logging at PyCon AU 2018.
Curtis Maloney presented “A Guided Tour of Python Logging” at PyCon AU, covering the logging system’s conceptual structure, configuration, and practical use. Source: PyCon AU.

logging.exception() captures the active traceback

The simplest pattern is to catch the exception you expect to handle and log it inside the except block.

import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

try:
    value = 10 / 0
except ZeroDivisionError:
    logger.exception("Calculation failed")

The log record contains your message plus the exception type, message, file, line number, and traceback frames. You do not need to manually call traceback.print_exc() just to get the traceback into the logging system.

The important rule is placement. This is correct:

try:
    load_configuration()
except OSError:
    logger.exception("Configuration load failed")

This is usually wrong:

logger.exception("Something failed")  # no active exception here

Outside an active exception handler, there may be no meaningful exception state to attach. If you simply need an ERROR message, use logger.error().

PyCon AU’s “A Guided Tour of Python Logging” provides a deeper look at the standard library logging architecture used throughout this lesson.

exc_info=True gives other log levels a traceback

logger.exception() is effectively an ERROR-level convenience method. Sometimes the event belongs at another severity level. In that case, use the normal logging method and set exc_info=True.

try:
    cached_value = cache.read("user:42")
except CacheError:
    logger.warning(
        "Cache read failed; falling back to database",
        exc_info=True,
    )
    cached_value = database.read_user(42)

The event is a WARNING because the application recovered, but the traceback is still preserved for investigation. The same technique works with debug(), info(), error(), and critical().

The key distinction is therefore:

  • logger.exception("message") logs at ERROR and includes exception information.
  • logger.error("message", exc_info=True) also logs at ERROR and includes exception information.
  • logger.warning("message", exc_info=True) includes the traceback but records the event at WARNING.

exc_info and stack_info are not the same thing

The official logging API exposes both exc_info and stack_info, but they answer different debugging questions.

exc_info records the stack frames associated with an exception that has been raised and unwound while Python searched for a handler.

stack_info=True records the current call stack leading to the logging call, even if no exception occurred.

def validate_state(state):
    if state == "unexpected":
        logger.warning(
            "Unexpected state reached",
            stack_info=True,
        )

This can be valuable when you need to know how execution reached a suspicious branch without intentionally throwing an exception.

Not sure who needs to hear this but excelling python logging tutorialGreat explanation of underlying mental model, modern best practices, and customization; always wrankles me that intros mostly teach antipatterns (e.g. using primarily root logger)youtu.be/9L77QExPmI0?…

— Emily Riederer (@emilyriederer.bsky.social) 2024-05-19T13:36:11.903Z
Emily Riederer highlights a Python logging tutorial for its explanation of the logging mental model, modern practices, and customization.

Log at the layer that has enough context

A common production mistake is logging the same exception at every layer of the call stack.

def read_record():
    try:
        return database.read()
    except DatabaseError:
        logger.exception("Database failed")
        raise


def handle_request():
    try:
        return read_record()
    except DatabaseError:
        logger.exception("Request failed")
        raise

One failure can now produce two nearly identical tracebacks. In a high-volume system that creates noise, wastes storage, and makes alert counts misleading.

A stronger design is to choose the layer that has enough business or operational context to make the record useful. Lower layers may simply raise the exception. The boundary layer logs it once with identifiers such as request ID, job ID, customer ID, file name, or operation name when those identifiers are safe to record.

def read_record():
    return database.read()


def handle_request(request_id):
    try:
        return read_record()
    except DatabaseError:
        logger.exception(
            "Request failed",
            extra={"request_id": request_id},
        )
        raise

Log and re-raise when the caller still needs the failure

Logging an exception does not handle it automatically. After logging, decide whether the program can recover.

try:
    save_invoice(invoice)
except DatabaseError:
    logger.exception("Invoice write failed")
    raise

A bare raise inside the handler re-raises the same exception with its original traceback. This is usually preferable to raise error when your intent is simply to propagate the current exception.

If the layer needs to translate an implementation-specific exception into a domain exception, use exception chaining:

try:
    customer = repository.load(customer_id)
except DatabaseError as exc:
    raise CustomerLookupError(customer_id) from exc

The from exc relationship preserves the causal chain, which makes the final traceback much more useful.

Do not log secrets just because an exception exposes them

Tracebacks are diagnostic data, and diagnostic data can contain sensitive information. URLs may contain API keys. Request objects can contain authorization headers. Database errors can expose query values. File paths can reveal usernames or internal directory structures.

Before logging arbitrary objects or exception messages, decide what the log is allowed to contain. Never intentionally place passwords, private keys, session cookies, authentication tokens, recovery codes, or full secret-bearing request payloads into a production log.

# Avoid this if request_headers may contain credentials.
logger.exception("Request failed: %r", request_headers)

# Prefer known-safe fields.
logger.exception(
    "Request failed method=%s path=%s",
    method,
    safe_path,
)

Practical example: resilient file processing

import json
import logging
from pathlib import Path

logger = logging.getLogger(__name__)


def load_json(path: Path):
    try:
        with path.open("r", encoding="utf-8") as handle:
            return json.load(handle)
    except FileNotFoundError:
        logger.warning(
            "Input file does not exist path=%s",
            path,
            exc_info=True,
        )
        return None
    except json.JSONDecodeError:
        logger.exception("Invalid JSON path=%s", path)
        raise


def main():
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
    )

    data = load_json(Path("settings.json"))
    if data is None:
        logger.info("Using built-in defaults")


if __name__ == "__main__":
    main()

The two exceptions represent different operational meanings. A missing optional file is recoverable, so the program logs a WARNING and uses defaults. Malformed JSON means the file exists but cannot be trusted, so the function logs the traceback and re-raises the failure.

Exercises

  1. Write a function that divides two integers. Catch ZeroDivisionError and record the traceback with logger.exception().
  2. Rewrite the same handler using logger.error(..., exc_info=True). Compare the output.
  3. Change the level to WARNING while keeping the traceback.
  4. Create a three-function call chain and add stack_info=True to the deepest function without raising an exception. Compare that output with a real exception traceback.
  5. Create a lower-level function that raises an exception and a higher-level function that logs it once. Verify that the same traceback is not duplicated.
  6. Add a safe request ID with extra while ensuring that an imaginary API token is never logged.

Knowledge check

  1. What severity level does logger.exception() use by default?
  2. Where should logger.exception() normally be called?
  3. How can a WARNING-level record include an exception traceback?
  4. What is the difference between exc_info=True and stack_info=True?
  5. Why can logging the same exception at every layer be harmful?
  6. What does a bare raise do inside an exception handler?
  7. Why should exception logs be reviewed for sensitive data?

Answer guide

  1. ERROR.
  2. Inside an active exception handler, normally an except block.
  3. Use logger.warning("message", exc_info=True).
  4. exc_info records exception traceback information; stack_info records the current call stack leading to the logging call even without an exception.
  5. It creates duplicate tracebacks, increases noise and storage, and can distort alert and incident counts.
  6. It re-raises the currently handled exception while preserving its traceback.
  7. Exception messages and surrounding objects can contain credentials, tokens, customer data, internal paths, or other sensitive information.

Key takeaway

A useful exception log answers three questions: what operation failed, where did the failure originate, and what context will help someone reproduce or diagnose it without exposing secrets.

logger.exception() is the shortest path to a traceback when you are already inside an exception handler. exc_info=True gives you the same traceback capability at other log levels, while stack_info=True answers a different question by showing how execution reached the logging statement itself.


BitcoinVersus.Tech

Advertisement

Follow BitcoinVersus.Tech for independent technology reporting and open-source technical education.

BitcoinVersus.Tech Editor’s Note:

We volunteer daily to ensure the credibility of the information on this platform is Verifiably True. If you would like to support to help further secure the integrity of our research initiatives, please donate here: 3C9o19EH5HSiwEPyCTmEKzxhNCbo2X6TTb

BitcoinVersus.tech is not a financial advisor. This media platform reports on financial subjects purely for informational purposes.

Leave a Reply