Skip to content

Adding Notes to Exceptions in Async Code

A traceback from inside a concurrent fan-out says where the code failed but not for which request, item or tenant — and in async code the frames that knew that context may belong to another task. BaseException.add_note(), added in Python 3.11, attaches strings to an exception that appear wherever it is printed. Measured on Python 3.14: a ValueError raised in one of two tasks in a TaskGroup, annotated with order_id=1002 request_id=req-7f3a by a helper around the call, was logged by logging.exception with the note printed under the error line. Notes added in a child task and in the task awaiting it both survived: the exception arrived with 2 notes, in order. A contextlib helper that adds a note cost 0.43 µs per use when nothing was raised; a class-based one 0.10 µs; inline try/except 0.02 µs. One trap: re-raising the same exception object through three retry attempts left it with 3 notes, one per attempt. This guide adds context where it is known and keeps it accurate.

Prerequisites

1. Add context where it is known

The function that knows the order ID is rarely the one that raises. Catch, annotate and re-raise at the level that has the context:

async def handle(order_id: int, raw: str):
    try:
        return await parse(raw)
    except Exception as e:
        e.add_note(f"order_id={order_id} request_id={request_id.get()}")
        raise

Measured with two orders handled concurrently in a TaskGroup, one of which failed to parse: logging.exception("batch failed") in an except* handler printed the ValueError: invalid literal for int() with base 10: 'x7' line followed by order_id=1002 request_id=req-7f3a. The traceback module renders notes under the exception message, so anything built on it — the standard logging formatter, traceback.print_exc, the default excepthook — shows them without configuration. A bare raise re-raises the same object with its original traceback; the note is the only change.

Verify: a forced failure in a fan-out produces a log entry that names the item and request, without searching other log lines.

Exception notes in practice, Python 3.14 A grid of 5 rows by 2 columns. Exception notes in practice, Python 3.14 check result note in logging.exception output yes, printed under the error line notes added in child task + awaiting task both kept, in order helper cost, nothing raised contextlib 0.43 us, class 0.10 us, inline 0.02 us raise + catch, without / with a note 0.07 us / 0.83 us same exception object, 3 retry attempts 3 notes, one per attempt Costs per use, averaged over 100,000 to 1,000,000 iterations.

2. Wrap it in a cheap helper

Repeating try/except around every call site is noisy; a context manager reads better. Its cost on the success path matters, because it runs on every call:

class note:
    __slots__ = ("msg",)

    def __init__(self, msg: str):
        self.msg = msg

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        if isinstance(exc, Exception):           # not CancelledError or KeyboardInterrupt
            exc.add_note(self.msg)
        return False                             # never suppress

async def handle(order_id, raw):
    with note(f"order_id={order_id}"):
        return await parse(raw)

Measured with nothing raised: this class-based helper cost 0.10 µs per use; a @contextlib.contextmanager version cost 0.43 µs, because it creates and drives a generator; inline try/except cost 0.02 µs. On a hot path executed millions of times, the generator version adds up; anywhere near I/O, none of them do. The f-string is evaluated on every call, even when nothing fails — keep it cheap, or pass a callable that builds the message only on failure.

Verify: the helper skips CancelledError, never suppresses, and its per-call cost on the hot path is measured.

3. Let notes cross task boundaries

An exception raised in a child task is re-raised in the task that awaits it, as the same object. Notes added on either side accumulate:

async def inner():
    with note("inside inner task"):
        raise KeyError("k")

try:
    with note("awaiting inner from outer"):
        await asyncio.create_task(inner())
except KeyError as e:
    print(e.__notes__)          # ['inside inner task', 'awaiting inner from outer']

Measured: both notes were present, in the order they were added — innermost first. Inside a TaskGroup, failures arrive wrapped in an ExceptionGroup, and the individual exceptions inside it keep their notes; a note added to the group itself describes the group, so add per-item context in each child, as in collecting errors without cancelling siblings.

Verify: an exception raised in a child task shows notes from both the child and the awaiting code.

Notes accumulating across tasks A sequence of 5 messages between 4 participants. Notes accumulating across tasks parse() child task TaskGroup host logging ValueError('x7') add_note('order_id=1002 request_id=req-7f3a') wrapped in ExceptionGroup logging.exception in except* error line + note printed The exception object carries its context to wherever it is logged.

4. Avoid duplicated notes on retries

Notes are stored on the exception object. When the same object is raised more than once — a cached exception, a stored failure re-raised to several waiters, a retry loop that re-raises the previous attempt's error — each pass adds another note:

err = ConnectionError("down")

async def flaky():
    raise err                                   # the same object every time

for attempt in range(3):
    try:
        with note(f"attempt {attempt}"):
            await flaky()
    except ConnectionError:
        pass

print(err.__notes__)        # ['attempt 0', 'attempt 1', 'attempt 2']

Measured: three attempts left three notes on one object. For a retry loop that is arguably useful — it records every attempt — but for a cached failure delivered to many requests, each request's note ends up on everyone's exception. Raise a new exception chained to the stored one — raise RequestFailed(...) from cached_error — when an error object is shared, and annotate the new one. For retry loops, add one summary note at the end instead, as in logging retries usefully.

Verify: no exception object shared between requests receives request-specific notes.

5. Choose notes or structured logging

Notes are strings attached to one exception. Structured logs carry fields that can be searched and aggregated. Use both, for different purposes:

try:
    await process(order)
except Exception as e:
    e.add_note(f"order_id={order.id} tenant={order.tenant}")      # for whoever prints the traceback
    log.exception("order failed", extra={"order_id": order.id, "tenant": order.tenant})
    raise

Notes travel with the exception into places your logging configuration does not reach — a framework's error page, an error tracker that renders tracebacks, a test failure report. Structured fields make "how many failures for tenant X" a query. Keep personal data out of notes: they are printed wherever the traceback goes, including places with weaker access controls than your logs. The cost is small either way: raising and catching an exception took 0.07 µs, and 0.83 µs with a note added — negligible on any failure path.

Verify: tracebacks in an error tracker show notes, and structured logs carry the same identifiers as fields.

Cost per use when nothing is raised 3 horizontal bars comparing @contextlib.contextmanager helper with the others. Cost per use when nothing is raised @contextlib.contextmanager helper 0.43 us class-based helper (__slots__) 0.10 us inline try/except 0.02 us The success path is what runs millions of times.

Verification

Exception notes are used well when:

  • Context is added where it is known, at the call site with the request or item.
  • A cheap helper adds notes only to Exception subclasses and never suppresses.
  • Shared exception objects are wrapped, not annotated, so notes do not leak between requests.
  • Notes and structured logs carry the same identifiers, with no personal data in notes.

Diagnostic Hook: when an exception's traceback lists the same kind of note several times, check whether one exception object is being re-raised. Three retries of a shared ConnectionError left three notes on it in this test.

Pitfalls & edge cases

  • Notes on shared exception objects. Measured: one note per raise accumulates.
  • A generator-based helper on hot paths. Measured: 0.43 µs per use against 0.10 µs.
  • Annotating CancelledError. Cancellation is not a failure to explain; skip it.
  • Personal data in notes. They appear wherever tracebacks are shown.

Frequently Asked Questions

How do I add context to an exception in Python?

Call e.add_note("...") on it and re-raise (Python 3.11+). The note is printed under the exception message in tracebacks and logging.exception output.

Do exception notes survive across asyncio tasks?

Yes. A note added in a child task and another added where the task was awaited were both present, in order, on the exception the caller caught.

Does add_note slow down my code?

Only when an exception is raised: 0.83 µs for raise, note and catch. A class-based helper cost 0.10 µs per use on the success path.

Why does my exception have the same note several times?

The same exception object was raised several times, each pass adding a note: three retries gave three notes. Raise a new exception chained to the shared one.