Handling Specific and Multiple Exceptions

Real programs fail in many different ways, and your error handling becomes clearer when you distinguish between them. Instead of a single giant except Exception, most routines end up with a small ladder of specific clauses: one for bad user input, one for missing files, one for network problems. Each clause does something appropriate to that kind of failure, so the next developer (including future-you) can see the intent at a glance.

Python supports this in two complementary ways. First, you can list several except clauses after a single try, and Python picks the first one that matches. Second, a single except can name a tuple of classes: except (ValueError, TypeError) as err:. Use multiple clauses when each failure needs different handling; use a tuple when the handling is the same.

Matching is by inheritance, not by exact class. except OSError catches FileNotFoundError, PermissionError and friends because they subclass it. That is why the standard advice is to order clauses from most specific to most general: if except OSError comes first, the except FileNotFoundError below it will never run.

Python 3.11 added exception groups (ExceptionGroup) and the except* syntax for handling multiple exceptions that were raised concurrently, typically by asyncio.TaskGroup. You will mostly ignore them until you write concurrent code, but it is worth knowing they exist so you recognize except* in modern codebases.

Multiple clauses vs a tuple

Use multiple clauses when each kind of failure gets a different response: log a warning for ValueError, prompt to re-enter for KeyboardInterrupt, exit with a status code for SystemExit. Use a tuple when the same recovery code covers several classes: except (ConnectionError, TimeoutError):.

Always put specific classes before their parents. except FileNotFoundError first, then except OSError. The reverse order is a common source of “my specific handler never runs” bugs.

Preserving context with raise...from

When you catch a lower-level exception and want to raise a higher-level one, use raise DomainError(...) from err. The __cause__ link keeps the original traceback visible and makes debugging much easier than wrapping manually.

If you want to suppress the implicit “during handling of above exception, another exception occurred” message (rare), use raise MyError(...) from None. Prefer from err in almost every case.

Tools for narrow, precise exception handling.

ToolPurpose
except Specific
clause
Matches one exception class.
except (A, B)
clause
Matches any of several classes with the same code.
except BaseExc as err
clause
Binds the exception to `err` for inspection.
raise X from err
statement
Wraps an exception while preserving the cause.
ExceptionGroup
class (3.11+)
Groups multiple concurrent exceptions.
except* A
clause (3.11+)
Matches classes inside an ExceptionGroup.
contextlib.suppress
context manager
Ignores specified exceptions inside its body.
issubclass(err_cls, OSError)
built-in
Check matching rule outside try/except.

Handling Specific and Multiple Exceptions code example

The script below models a tiny configuration loader that treats each kind of failure differently.

# Lesson: Handling Specific and Multiple Exceptions
from contextlib import suppress
from pathlib import Path


class ConfigError(Exception):
    pass


def load_config(path: Path) -> dict[str, str]:
    try:
        raw = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return {"source": "default"}
    except PermissionError as err:
        raise ConfigError(f"cannot read {path}") from err
    except OSError as err:
        raise ConfigError(f"unexpected OS error for {path}") from err

    result = {}
    for i, line in enumerate(raw.splitlines(), start=1):
        if not line or line.startswith("#"):
            continue
        try:
            k, v = line.split("=", 1)
        except ValueError as err:
            raise ConfigError(f"bad line {i}: {line!r}") from err
        result[k.strip()] = v.strip()
    return result


# Missing file -> defaults
print("missing:", load_config(Path("does_not_exist.conf")))

# Bad line -> wrapped ConfigError
from tempfile import gettempdir
p = Path(gettempdir()) / "bad.conf"
p.write_text("host=localhost\nnot-a-pair\n", encoding="utf-8")
try:
    load_config(p)
except ConfigError as err:
    print("domain error:", err, "| cause:", type(err.__cause__).__name__)

# Ignoring specific errors with contextlib.suppress
with suppress(FileNotFoundError):
    Path("nope.txt").unlink()
print("survived suppress")

p.unlink()

Walk through the three clauses:

1) Specific first: FileNotFoundError and PermissionError come before OSError.
2) Each clause either recovers (FNF) or wraps into a domain error (Permission, OS).
3) The line-level ValueError is turned into ConfigError for a precise failure message.
4) `contextlib.suppress(FNF)` is the cleanest way to ignore a single-class error.

Practice multi-class handling with a tuple and a re-raise.

from urllib.error import URLError, HTTPError

def classify(err: Exception) -> str:
    if isinstance(err, (HTTPError, URLError)):
        return "network"
    if isinstance(err, (ValueError, TypeError)):
        return "data"
    return "other"

for cls in (HTTPError("u", 500, "msg", {}, None), ValueError(), RuntimeError()):
    print(type(cls).__name__, "->", classify(cls))

Check the matching rule.

try:
    {}["missing"]
except (KeyError, IndexError):
    pass
assert issubclass(FileNotFoundError, OSError)
assert not issubclass(KeyError, ValueError)

Running prints:

missing: {'source': 'default'}
domain error: bad line 2: 'not-a-pair' | cause: ValueError
survived suppress