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¶
- Python 3.11+ for
ExceptionGroup,except*andTaskGroup. - TaskGroup semantics from Exception Groups & TaskGroups — a group aggregates every failure from its children.
- Structured logging from structured logging for async services if your logs are JSON.
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.
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.
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*orsplitre-raises what you did not handle.
Pitfalls & edge cases¶
except Exceptionaround aTaskGroup. It catches the group as a whole, and a group containing aCancelledErroris aBaseExceptionGroupthat this clause misses entirely.- Iterating
eg.exceptionsonce. 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
TaskGroupwith one failing child still raises a group; do not special-case the count. - Swallowing
CancelledError.except*clauses that matchBaseExceptioncatch 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.
Related¶
- Exception Groups & TaskGroups — up to the topic overview.
- Migrating from gather to TaskGroup — where these groups start appearing.
- Structured logging for async services — the logging setup these records feed into.
- Resilience, Cancellation & Error Handling — the section overview.