Before Python 3.11, exception handling had a fundamental limitation: you could only propagate one exception at a time. If three concurrent tasks failed simultaneously, you had to pick one error and throw away the rest, or stuff them into a custom wrapper. Exception groups fix this by making multiple simultaneous errors a first-class concept in the language.
ExceptionGroup is a built-in exception type that wraps a list of exceptions under a single message. The companion except* syntax (pronounced "except star") lets you pattern-match against the contents of a group, handling different error types in separate clauses while automatically re-raising anything you didn't handle. This is not just a concurrency feature. Any situation where multiple independent operations can fail — form validation, batch processing, parallel I/O — benefits from being able to report all failures at once instead of stopping at the first one.
Master python exception groups
Take the Python Fundamentals course with hands-on lessons and challenges.
Without exception groups you'd return a list of error strings or raise on the first failure. ExceptionGroup lets you use standard exception handling (try/except*) while reporting every problem at once — the caller decides how to present them.
When multiple HTTP requests fail in a TaskGroup, the errors arrive bundled in an ExceptionGroup. Each except* clause handles one category independently — HTTP errors get logged with status codes, network errors get a different message. Successful results are still available on completed tasks.
The split() method returns a tuple of (matching_group, rest_group) — either can be None if nothing matched. This is the cleanest way to implement retry logic: separate transient failures from permanent ones, re-run only the transient batch, and propagate permanent failures immediately.
In practice you almost always use ExceptionGroup. BaseExceptionGroup exists because TaskGroup needs to handle cases where KeyboardInterrupt or SystemExit happens during concurrent execution. The constructor auto-selects the right type based on what you put in it, so you rarely need to think about this unless you're building a framework.
Exception groups can nest. Each sync_user call collects its own failures into a group, and when TaskGroup collects those per-user groups, you get a tree of errors. The except* clauses match at any depth, so you can handle ConnectionError at the top level even if some are nested inside per-user groups.
Mixing `except` and `except*` in the same `try` block — this is a `SyntaxError`
Choose one style per `try` block. If you need to handle both grouped and non-grouped exceptions, either use `except*` for everything (it works even when only one exception matches) or wrap the `except*` block inside a regular `try/except`.
Assuming `except*` catches the exception directly — it actually catches an `ExceptionGroup` containing matches, so `eg` is always a group, never a bare exception
Always iterate `eg.exceptions` to access the individual exceptions. Even if only one exception matched, `eg.exceptions` is a tuple of length 1, not the exception itself.
Silently swallowing exceptions by catching `except* Exception` without re-raising unhandled ones
Be specific with your `except*` clauses. Unlike regular `except`, unmatched exceptions inside the group are automatically re-raised — but if you catch `Exception`, everything matches and nothing propagates. Catch only the types you actually handle.
Trying to raise `ExceptionGroup` with an empty list — `ExceptionGroup("msg", [])` raises a `ValueError`
Guard with `if errors: raise ExceptionGroup(...)`. An exception group must contain at least one exception. This is a deliberate design choice to prevent meaningless empty groups from propagating.
Exception groups solve a real gap in Python's error model: handling multiple simultaneous failures without losing information. ExceptionGroup wraps a list of exceptions, and except* pattern-matches against their types — each clause runs independently, and unhandled exceptions re-raise automatically. Combined with asyncio.TaskGroup for structured concurrency, this gives you clean error handling for concurrent code without manual bookkeeping. The split() and subgroup() methods make retry logic and error routing straightforward. Available since Python 3.11, with a backport (exceptiongroup package) for 3.10 and earlier.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.