Standardizing Docstring Conventions for Exception Groups (PEP 654/`except*`)

PEP 654 introduced ExceptionGroup and BaseExceptionGroup alongside the except* syntax in Python 3.11. While PEP 654 defined the runtime mechanics and interpreter behavior for exception groups, it did not address docstring conventions. As a result, PEP 257 (Docstring Conventions) and major style guides (such as Google or NumPy style) currently leave developers without a consensus on how to document functions raising them.

Because exception groups fundamentally change how caller code must handle errors, the lack of a standard documentation convention leads to misleading documentation and runtime errors for library users.


The Problem

Consider a function that raises an ExceptionGroup (or BaseExceptionGroup):

def process_batch():
    ex1 = ValueError("invalid argument in task 1")
    ex2 = TypeError("type mismatch in task 2")

    raise ExceptionGroup("batch failure", [ex1, ex2])

# Standard exception catching WILL NOT work:
try:
    process_batch()
except (ValueError, TypeError):
    print("This block is never reached!")  # Unhandled ExceptionGroup!

# Callers MUST use one of these two forms:
try:
    process_batch()
except ExceptionGroup:
    print("Caught the group generically")

try:
    process_batch()
except* (ValueError, TypeError):
    print("Handled specific sub-exceptions via except*")

Prior Context

I previously opened issues on popular style guide repositories to discuss this, but neither has reached a resolution (I can’t turn these into URLs due to a maximum hyperlink restriction on new users, sorry):

  • Google Python Style Guide Issue #907
  • Ruff Issue #16961
  • NumPy Docstring Standard Issue #684

Why Existing Docstring Practices Don’t Work

1. Documenting only the inner exceptions

def process_batch():
    """
    Raises:
        ValueError: If task 1 fails.
        TypeError: If task 2 fails.
    """
  • Issue: This strongly implies that standard try ... except ValueError blocks will work. Callers reading this might write code that crashes at runtime when an ExceptionGroup is raised instead.

2. Documenting only ExceptionGroup

def process_batch():
    """
    Raises:
        ExceptionGroup: If any task in the batch fails.
    """
  • Issue: This hides the inner exceptions entirely. Callers who want to handle specific error cases via except* ValueError have no way of knowing what sub-exceptions can occur without reading the underlying source code, defeating the purpose of docstrings.

Proposed Conventions for Discussion

To clearly signal both that an exception group is raised and what sub-exceptions it contains, here are four potential approaches I’ve come up with:

Option A: Generic Type Notation

Utilize typing-like syntax to denote contained types:

def process_batch():
    """
    Raises:
        ExceptionGroup[ValueError, TypeError]: Raised when batch tasks fail.
            Contains details on argument or type mismatches.
    """
  • Pros: Highly concise, aligns naturally with Python typing syntax.
  • Cons: Harder to write individual prose descriptions for each sub-exception type.

Option B: Hierarchical Structure

Use a nested block structure under ExceptionGroup:

def process_batch():
    """
    Raises:
        ExceptionGroup: Raised if one or more batch tasks fail.
            ValueError: If task arguments are invalid.
            TypeError: If task types are mismatched.
    """
  • Pros: Clean, highly readable, allows per-exception descriptions.
  • Cons: May require updates to docstring parsers (like Sphinx/Napoleon or Griffe) to avoid misinterpreting nested items as list continuation text; verbose.

Option C: Syntax-Mirroring (*Exception)

Use an asterisk prefix to visually match Python’s except* syntax:

def process_batch():
    """
    Raises:
        *ValueError: Raised inside an ExceptionGroup if task arguments are invalid.
        *TypeError: Raised inside an ExceptionGroup if task types mismatch.
    """
  • Pros: Explicitly informs the reader that except* is required to catch it.
  • Cons: Doesn’t explicitly state the parent ExceptionGroup or BaseExceptionGroup type; unconventional markup syntax.

Option D: Explicit Qualifier Syntax

def process_batch():
    """
    Raises:
        ValueError (via ExceptionGroup): If task arguments are invalid.
        TypeError (via ExceptionGroup): If task types are mismatched.
    """
  • Pros: Makes it trivial to adapt docstring parsers to recognize, without requiring actual parser changes.
  • Cons: Verbose when multiple exceptions are involved; highly unconventional markup syntax.

Relevant Questions to be Answered

  1. From an AST parsing and rendering perspective, which of these options (or another alternative) would be the easiest and cleanest to support?
  2. How are devs currently documenting functions that raise ExceptionGroup or use anyio/asyncio taskgroups?
  3. Is there interest in establishing an consensus/guideline so docstring tools don’t fragment on this?

I’d love to hear thoughts, alternative proposals, or preferences! Thank you for your time.

4 Likes

Thanks, great write-up :slightly_smiling_face:

Linked issues until you can edit your post to include them:

To me, option A and C are the most interesting ones.

As mentioned, B makes it hard to know whether we deal with continuation lines or additional, parse-able information.

C doesn’t make ExceptionGroup or BaseExceptionGroup part of the encoded information, but should it, really? I never used exception groups yet. With regular exceptions, we don’t really care from within the docstring of the function that raises them whether they derive from Exception or BaseException. Documentation readers can always check the exception class itself in the docs (or wherever it’s documented) to learn that. Does ExceptionGroup or BaseExceptionGroup changes the way you actually catch the exceptions / exception group? Now, from a rendering perspective, it might still be very valuable to display ExceptionGroup in addition to the exception classes themselves, since one can catch the group directly. And if it’s not explicit in the docstring, then rendering tools would have to guess, and could be wrong.

D feels very verbose, repeating ExceptionGroup for each exception.

One thing to consider maybe, to eliminate some options, is that a single function could raise different exception groups:

% python
Python 3.14.6 (main, Jun 15 2026, 11:36:54) [GCC 16.1.1 20260430] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def f(x):
...     if x:
...         raise ExceptionGroup("x", [ValueError("bla"), RuntimeError("bla")])
...     raise ExceptionGroup("not x", [OSError("bla"), ZeroDivisionError("bla")])
...
>>> f(0)
  + Exception Group Traceback (most recent call last):
  |   File "<python-input-1>", line 1, in <module>
  |     f(0)
  |     ~^^^
  |   File "<python-input-0>", line 4, in f
  |     raise ExceptionGroup("not x", [OSError("bla"), ZeroDivisionError("bla")])
  | ExceptionGroup: not x (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | OSError: bla
    +---------------- 2 ----------------
    | ZeroDivisionError: bla
    +------------------------------------
>>> f(1)
  + Exception Group Traceback (most recent call last):
  |   File "<python-input-2>", line 1, in <module>
  |     f(1)
  |     ~^^^
  |   File "<python-input-0>", line 3, in f
  |     raise ExceptionGroup("x", [ValueError("bla"), RuntimeError("bla")])
  | ExceptionGroup: x (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: bla
    +---------------- 2 ----------------
    | RuntimeError: bla
    +------------------------------------
>>>

(Unless these are caught the same way anyway? Does raising different groups makes a difference when catching them?)

How well does each option support that?

  • A: supports it well.
  • B: support it too, with a reservation on the repeated item name (ExceptionGroup)
  • C: doesn’t support it, as it explodes groups
  • D: doesn’t support it, as it doesn’t explain which group an exception pertains to

Let me suggest option E:

def process_batch():
    """
    Raises:
        (ValueError, TypeError): If task arguments are invalid, or task types are mismatched, respectively.
    """
  • Pros: Feels intuitive, no redundant info, grouping preserved, easy to parse.
  • Cons: Still not easy to provide details for each specific exception, requiring markup or repeating exceptions in the description.

If ExceptionGroup vs. BaseExceptionGroup turns out to be important to document, then A is better. Syntax can use parentheses or square brackets, spaces or no spaces, no strong opinion. A with parentheses and spaces: ExceptionGroup (ValueError, TypeError): ....

1 Like

Option E: B with asterisks from C

def process_batch():
    """
    Raises:
        ExceptionGroup: Raised if one or more batch tasks fail.
            *ValueError: If task arguments are invalid.
            *TypeError: If task types are mismatched.
    """
1 Like

Thanks for the response! Visually I definitely agree with you there, if I could pick one that is magically perfeect that’d be your option E, the main issue is like pawamoy said where it’s harder on parsers. I’ll try to do some investigation on how hard it gets to know whether we deal with continuation lines or additional, parse-able information, maybe the asterisks are a good indicator.

Thanks for your response! The example with multiple possible groups is something really relevent that I hadn’t considered, and I think it favors Option A

To answer your question: ExceptionGroup vs BaseExceptionGroup does affect how the raised object can be caught. Much like Exception vs BaseException, except Exception catches an ExceptionGroup, but not a BaseExceptionGroup. Meanwhile, except* matches the exceptions contained within a group, and a group class itself cannot be used as the matching type in an except* clause

For handling through except*, two differently composed groups are matched in the same type-based way. However, the matching subgroup preserves the original group’s nesting and metadata, and callers may also catch or inspect the whole group directly. So I agree that a convention should be able to distinguish separate group outcomes, even if it doesn’t attempt to describe the complete exception tree

So maybe we could rethink option E as:

Raises:
    ExceptionGroup[ValueError | RuntimeError]:
        If ``x`` is true.
    ExceptionGroup[OSError | ZeroDivisionError]:
        If ``x`` is false.

I changed the comma-separated arguments from my original Option A to a union because exception groups are generic over a single contained-exception type. ExceptionGroup[ValueError | TypeError] is therefore closer to genuine typing syntax than ExceptionGroup[ValueError, TypeError]

The remaining weakness is individual descriptions. One possible compromise would be to make the Option A line the only structurally parsed item and allow ordinary bullets within its description:

Raises:
    ExceptionGroup[ValueError | TypeError]:
        Raised if one or more batch tasks fail.

        * ``ValueError``: Task arguments are invalid.
        * ``TypeError``: Task types are mismatched.

The idea is that this would preserve the group as a single parseable entry, avoid the continuation line ambiguity of Option B, and still allow documentation renderers to show details for each contained exception. Does that make sense?

Does this also work for you @tjreedy?

That’s generally supported already :+1: Arbitrary markup can be used in item descriptions.

Regarding the syntax:

  • ExceptionGroup[ValueError | TypeError] feels correct, but maybe too typing-related
  • ExceptionGroup (ValueError, TypeError) feels more consistent with the rest of the style (prose-looking)

No strong opinion really.

Suggestion for the Numpydoc style:

Raises
------
ExceptionGroup : ValueError, TypeError
    Description.
1 Like

That makes sense, thanks. If arbitrary markup is already supported in the description, then that addresses the issue about documenting the individual contained exceptions separately

Regarding the syntax, I can see the trade-off you’re pointing out. ExceptionGroup[ValueError | TypeError] sounds better to me because it reuses meaningful Python syntax and makes the relationship between the group and its possible contained exception types immediately recognizable, at the same time, I can see how it might feel more like a type annotation than the rest of the Google-style Raises section. It could also raise questions for tooling about how much type-expression syntax it is expected to understand

So I don’t feel strongly about brackets versus parentheses. Something like:

ExceptionGroup(ValueError, TypeError): Description.

is more prose-like and may fit the existing style better, while:

ExceptionGroup[ValueError | TypeError]: Description.

is arguably more semantically expressive and reuses syntax Python developers already know.

The Numpydoc form also looks reasonable to me:

Raises
------
ExceptionGroup : ValueError, TypeError
    Raised if one or more batch tasks fail.

    * ``ValueError``: Task arguments are invalid.
    * ``TypeError``: Task types are mismatched.

Perhaps the most important part to standardize is the basic semantic structure:

  1. The exception-group class
  2. The possible contained exception types
  3. The condition under which the group is raised
  4. Descriptions for the individual contained types(?)

Then each docstring style could choose punctuation consistent with their own conventions, while parsers/tools could expose the same structured representation internally

Would something like this be reasonably parseable @pawamoy?

def process_batch(items, strict):
    """Process a batch of items.

    Raises:
        ExceptionGroup (ValueError, TypeError): Raised when validation fails.

            * ``ValueError``: An item's value is outside the accepted range.
            * ``TypeError``: An item has an unsupported type.

        ExceptionGroup (OSError, TimeoutError): Raised when external resources
            required by the batch cannot be accessed.

            * ``OSError``: A required file or service cannot be accessed.
            * ``TimeoutError``: An external operation exceeds its time limit.
    """
1 Like

Completely agree with your reasoning on the syntax :slight_smile:

Yes, definitely. Note that Griffe here would only parse ExceptionGroup (ValueError, TypeError) and ExceptionGroup (OSError, TimeoutError), and the rest would be stored as markup (no further parsing). The variant ExceptionGroup[ValueError | TypeError] would be equally easy to parse.

So, to me (or rather, to Griffe), the basic semantic structure is:

  1. The exception-group class
  2. The possible contained exception types
  3. An arbitrary description
1 Like

Thanks for the response! :wink:

That three part structure is exactly the minimum semantic model I was hoping we’d agree on, the concrete exception group class (custom subclasses included, if any), the possible exception types contained within it, and a description of the condition under which the group gets raised

For the second point I think the convention should just describe the possible leaf types without claiming to represent the group’s exact runtime nesting, ordering or multiplicity

Feels like a small enough, style independent semantic core that we could standardize across Google, NumPy and maybe Sphinx style docstrings first, if there’s broader agreement

Individual descriptions for the contained exception types I’d consider optional elaboration rather than part of that minimum structure, e.g.

Raises:
    ExceptionGroup (ValueError, TypeError):
        Raised if one or more batch tasks fail.

        * `ValueError`: A task received an invalid argument.
        * `TypeError`: A task received a value of the wrong type.

I get that Griffe currently just preserves those bullets as arbitrary markup, which makes sense since it doesn’t assume descriptions use Markdown specifically

What I’m wondering is whether tooling could go a step further (assuming we eventually land on an unambiguous style level convention for this optional elaboration), like e.g. once Griffe recognizes the outer item as an ExceptionGroup/BaseExceptionGroup (or a subclass), could it reasonably expose the contained exception types, and maybe even their descriptions, as structured info instead of leaving it all inside the description
string?

Not suggesting Griffe try to interpret arbitrary Markdown bullets btw, more trying to figure out if a constrained nested grammar would actually be useful to doc tooling, or if the contained types should just stay part
of the parsed annotation while descriptions remain renderer specific markup

@pawamoy Would you be open to a small Griffe issue or prototype that introduces an internal representation for the group type and possible leaf types, while leaving the description unstructured?

Contained exception types, yes. Description, hardly. The issue here is that:

  • we need nested syntax to support multiple groups
  • nested syntax doesn’t play well with how docstrings are currently parsed (continuation lines, indentation, different styles might need completely different syntax etc.)

That made me think: what about naming groups instead of using nested syntax? That would be option D with group names/numbers:

def process_batch():
    """
    Raises:
        ValueError (via ExceptionGroup 1): If task arguments are invalid.
        TypeError (via ExceptionGroup 1): If task types are mismatched.
        OSError (via ExceptionGroup 2): If some file couldn't be read.
        RuntimeError (via ExceptionGroup 2): If there was an unknown error.
    """

Rendering tools can then regroup exceptions together (automatically rendering an unordered list in HTML, in table cells, or any other style depending on the output format):

Exception Description
ExceptionGroup
ExceptionGroup
To get the same result with option A: (click to expand)

To get the same result with ExceptionGroup[ValueError | TypeError] etc., you’d need to write the docstring like such (at least with mkdocstrings):

def process_batch():
    """
    Raises:
        ExceptionGroup[ValueError | TypeError]: When user input is invalid.

            - [`ValueError`]: If task arguments are invalid.
            - [`TypeError`]: If task types are mismatched.
        ExceptionGroup[OSError | RuntimeError]: When execution fails.

            - [`OSError`]: If some file couldn't be read.
            - [`RuntimeError`]: If there was an unknown error.
    """

(Notice the extra blank lines before bullet lists, required depending on the Markdown parser down the line.)

Pros: I can’t really find any. I was going to mention automatic cross-references to symbol’s docs (ValueError in the stdlib docs, etc.), but actually option A would also autolink those, the exception type would simply be repeated with or without links in the markup.

Exception Description
ExceptionGroup[ValueError | TypeError] When user input is invalid.
  • ValueError: If task arguments are invalid.
  • TypeError: If task types are mismatched.
ExceptionGroup[OSError | RuntimeError] When execution fails.
  • OSError: If some file couldn’t be read.
  • RuntimeError: If there was an unknown error.

Cons: we cannot give a description to the group itself anymore. Explaining why specific exceptions are grouped together might be more important than describing each exception contained within the group. By the way, groups might contain arbitrary exceptions that a developer is not able to document (they don’t know the exception types in advance). In this case the flat syntax suggested above (revisited option D) is useless to the developer.

Yes :slight_smile: I think we can define a new group model that holds a description and several instances of the “raise” model. Each “raise” individual description is optional, so we effectively model both cases: exception group with exception types and a general description, and exception group with a description plus each exception type with a specific description.

1 Like