Skip to content

Logging ExceptionGroups with Full Tracebacks

The first time a TaskGroup fails in production, the log line usually reads:

ERROR svc: batch failed: unhandled errors in a TaskGroup (3 sub-exceptions)

Three tasks failed and the log names none of them — not a type, not a message, not a frame. That line comes from str() on the group, which is all an f-string or "%s" formatting will ever give you, and it is why services that adopted TaskGroup sometimes feel like they lost their observability. The information is all there; it lives in the group's tree of sub-exceptions, and getting it into your logs takes either one keyword argument or, for structured logging, a short recursive walk. This guide covers both, verified against a real group of three failures.

Prerequisites

What each logging call preserves 2 columns contrasting logger.error with %s, logger.exception. What each logging call preserves logger.error with %s one line, no detail only the group message sub-exception count only no types, no messages no frames at all logger.exception 31 lines, every leaf every leaf type named every message included a traceback per leaf the group tree drawn Measured on a group of three failures: str() named none of them, exc_info named all three.

1. Stop formatting the group as a string

The measurement is stark. For a group containing ValueError, KeyError and ConnectionError:

str(eg)                                    # 'unhandled errors in a TaskGroup (3 sub-exceptions)'
"ValueError" in str(eg)                    # False

Logging with exc_info instead produced 31 lines naming all three types, each with its own traceback:

try:
    async with asyncio.TaskGroup() as tg:
        ...
except* Exception:
    logger.exception("batch failed")       # exc_info=True, the whole tree

logger.exception() inside an except* block works exactly as it does in a normal except, and the standard traceback module renders the group as a tree with +-+--- branch markers, one sub-traceback per leaf. If your log pipeline handles multi-line records well, this alone fixes the problem.

There is a second, quieter version of the same loss. A task created with create_task() outside a group and never awaited reports its failure through the loop's exception handler, not through your logger at all, so it arrives with a different format and often a different destination. Groups at least keep the failures attached to the code that started them, which is most of why TaskGroup is worth the migration — see migrating from gather to TaskGroup.

Verify: grep your logs for "sub-exceptions" — every match is a failure you did not record.

2. Walk the tree to its leaves

Groups nest. TaskGroup puts a child's ExceptionGroup inside its own, and except* re-raises unhandled parts as new groups, so eg.exceptions gives you direct children that may themselves be groups. A recursive walk is the only correct traversal:

def iter_leaves(exc, path=()):
    if isinstance(exc, BaseExceptionGroup):
        for sub in exc.exceptions:
            yield from iter_leaves(sub, path + (exc.message,))
    else:
        yield path, exc

Against ExceptionGroup("outer", [ValueError, ExceptionGroup("inner", [KeyError, TypeError])]) this yields ValueError, KeyError, TypeError — the three actual failures, with the group path preserved for context. Iterating eg.exceptions once would have reported an ExceptionGroup as if it were a failure.

Use BaseExceptionGroup in the isinstance check, not ExceptionGroup: a group containing a CancelledError is a BaseExceptionGroup and would otherwise be treated as a leaf.

Verify: a deliberately nested group produces the right leaf list rather than a group object.

3. Emit one structured event per leaf

For JSON logs, one record per failure is what makes downstream grouping, alerting and deduplication work:

def log_group(eg, logger, **context):
    for path, exc in iter_leaves(eg):
        frame = traceback.extract_tb(exc.__traceback__)[-1] if exc.__traceback__ else None
        logger.error("task_failed", extra={
            "group": " / ".join(path),
            "error_type": type(exc).__name__,
            "error": str(exc),
            "site": frame.name if frame else None,
            "file": f"{frame.filename}:{frame.lineno}" if frame else None,
            **context,
        })
    logger.error("group_failed", exc_info=eg,
                 extra={"leaf_count": sum(1 for _ in iter_leaves(eg)), **context})

Which produced, for the three-task group:

{"event": "task_failed", "error_type": "ValueError", "error": "bad value", "site": "fail"}
{"event": "task_failed", "error_type": "KeyError", "error": "'missing'", "site": "fail"}
{"event": "task_failed", "error_type": "ConnectionError", "error": "upstream down", "site": "fail"}

traceback.extract_tb(...)[-1] gives the innermost frame — the function that actually raised — which is the field that makes an alert actionable. Emitting the full tree once with exc_info alongside the per-leaf events gives you both the searchable fields and the complete traceback in one place.

Verify: a three-failure group produces three searchable events plus one summary, and each names a distinct site.

Turning a group into log events 5 stages from catch the group to one summary line. Turning a group into log events catch the group with except* walk to the leaves groups nest format each leaf its own traceback emit one event each plus a group id one summary line counts by type One event per leaf is what makes alerting and grouping work downstream.

4. Attach request context with notes

add_note() attaches text to an exception that the standard formatter prints beneath it, which survives all the way through group formatting:

except* Exception as eg:
    for _, exc in iter_leaves(eg):
        exc.add_note(f"request_id={request_id}")
    raise

Verified: after adding a note to one leaf, the formatted group traceback contained request_id=abc123. This is how to carry correlation identifiers into a traceback that will be read hours later, and it works when the exception crosses a boundary your structured logger does not — a re-raise into a framework handler, a crash report, a sys.excepthook.

Notes live on the leaf, not the group, so add them in the walk rather than to the group object.

Verify: the note appears in the rendered traceback under the correct sub-exception.

5. Handle some failures and re-raise the rest

except* handles matching leaves and automatically re-raises the remainder, which is usually what you want:

try:
    async with asyncio.TaskGroup() as tg:
        ...
except* ConnectionError as eg:
    logger.warning("upstreams unavailable: %d", len(eg.exceptions))
    # anything that was not a ConnectionError propagates automatically

When you need the split explicitly — to route different failures to different destinations — split() and subgroup() do it without losing structure:

transient, rest = eg.split((ConnectionError, TimeoutError))
if transient:
    logger.warning("transient failures", exc_info=transient)
if rest:
    raise rest                                 # the ones that are real bugs

split returns a (matching, remaining) pair, either of which may be None; subgroup(predicate) returns just the matching part. Both preserve the nesting and each leaf's traceback, and derive() builds a new group with the same type and message when you need to rewrite the contents. Never rebuild a group by hand from eg.exceptions — you lose the tracebacks that make the log useful.

One more consideration for alerting: a group's leaves are often correlated — ten tasks failing with the same ConnectionError because one upstream went down is one incident, not ten. Counting by (error_type, site) and logging the multiplicity, rather than emitting ten identical alerts, keeps the signal readable:

counts = Counter((type(exc).__name__, frame_of(exc)) for _, exc in iter_leaves(eg))
logger.error("group_failed", extra={"failures": dict(counts), "total": sum(counts.values())})

That summary line is also the one to alert on, because its cardinality is bounded by the number of distinct failure modes rather than by the size of the batch.

Verify: after splitting, each part's leaves still format with their original tracebacks.

The ExceptionGroup API worth knowing A grid of 5 rows by 2 columns. The ExceptionGroup API worth knowing call returns use it for eg.exceptions the direct children, not leaves shallow inspection eg.subgroup(predicate) a group of matching leaves filtering by condition eg.split(ExcType) matching group, remainder handle some, re-raise the rest eg.derive([...]) same type and message, new leaves rewriting while preserving shape exc.add_note(text) nothing; annotates the leaf attaching request context Children may themselves be groups: always recurse rather than iterating once.

Verification

Group logging is complete when:

  • No log line ends in "sub-exceptions" without an accompanying traceback.
  • Nested groups are flattened by a recursive walk using BaseExceptionGroup.
  • Each leaf produces a searchable event with type, message and innermost frame.
  • The full tree is recorded once with exc_info.
  • Correlation context is attached via notes or log fields, on the leaves.
  • Unhandled parts propagate: except* or split re-raises what you did not handle.
A logging helper that loses nothing 5 ordered steps. A logging helper that loses nothing recurse to leaves groups can nest arbitrarily extract the last frame traceback.extract_tb(exc)[-1] one event per leaf type, message, site, group id the full tree once exc_info on a single record re-raise or handle never swallow the group Notes added with add_note appear in the formatted traceback, so context survives.

Pitfalls & edge cases

  • except Exception around a TaskGroup. It catches the group as a whole, and a group containing a CancelledError is a BaseExceptionGroup that this clause misses entirely.
  • Iterating eg.exceptions once. Nested groups are reported as failures with no message of their own.
  • raise eg.exceptions[0]. Discards every other failure — a common way to lose the actual root cause.
  • Sentry and similar tools. Check how your version fingerprints groups; older SDKs reported one event with the group's uninformative message.
  • Single-failure groups. A TaskGroup with one failing child still raises a group; do not special-case the count.
  • Swallowing CancelledError. except* clauses that match BaseException catch cancellation and break shutdown; match concrete types.

Frequently Asked Questions

Why does my log only say 'unhandled errors in a TaskGroup'?

Because that string is str() on the ExceptionGroup, and f-string or %s formatting never reaches the sub-exceptions. Log with exc_info — logger.exception("...") inside the except* block — which renders the whole tree, or walk the group and log each leaf yourself.

How do I get every exception out of an ExceptionGroup?

Recurse: for each item in eg.exceptions, if it is a BaseExceptionGroup recurse into it, otherwise yield it. Groups nest, so a single pass over eg.exceptions can hand you group objects instead of the actual failures.

How do I log each failure in a TaskGroup separately?

Walk to the leaves and emit one structured record per leaf with the exception type, its message and the innermost frame from traceback.extract_tb(exc.traceback)[-1]. Add one summary record with exc_info for the complete tree so nothing is lost.

Does add_note work on exceptions inside a group?

Yes. Notes attach to the individual exception and the standard formatter prints them under that sub-exception when the group is rendered, so a request id added to a leaf appears in the group traceback.

What is the difference between split and subgroup on an ExceptionGroup?

subgroup(predicate) returns a group containing only the matching leaves, or None. split(predicate) returns a pair — the matching group and the remainder — so you can handle one part and re-raise the other. Both preserve nesting and each leaf's traceback.