Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 7

Exception Handling

How Python signals, propagates and recovers from errors.

7.1 Errors versus Exceptions

"Error" is the umbrella term; Python code can go wrong in three different ways, only one of which this chapter's try/except machinery can do anything about:

Category Detected Crashes the program? Catchable with try/except?
Syntax error before execution starts, while parsing yes, immediately no — the code never even starts running
Logical error never automatically — code runs, gives the wrong answer no n/a — no exception is ever raised
Exception during execution, when an operation can't complete only if nothing catches it yes — this is what the rest of chapter 7 covers

A syntax error means the parser rejects the code outright — a missing : is invalid Python, not a runtime event:

Note — compile(source, filename, mode) is used here only to trigger a SyntaxError in a way that's safe to catch: filename is just a label attached to the error message (there's no real file being read — "<test>" is a conventional placeholder), and mode="exec" means "treat this as a full program" (as opposed to a single expression). It's a convenient way to check whether a string of code parses, without actually running it.

In [1]5 lines
# compile() parses without running -> demonstrates the SyntaxError without a broken cell
try:
    compile("if True\n    pass\n", "<test>", "exec")   # missing ':' after True
except SyntaxError as e:
    print("SyntaxError:", e)   # SyntaxError: expected ':' (<test>, line 1)
SyntaxError: expected ':' (<test>, line 1)

A logical error is the opposite extreme — the code is perfectly valid and runs to completion, it just computes the wrong thing. Nothing raises, nothing crashes, so try/except has nothing to catch — the only way to find this bug is to notice the output is wrong:

In [2]4 lines
def average(nums):
    return sum(nums) / len(nums) - 1   # bug: that trailing "- 1" shouldn't be there

print(average([2, 4, 6]))   # 3.0 -> wrong (should be 4.0), but runs fine, no exception at all
3.0

An exception sits in between: the code is syntactically valid, and most of the time it runs fine, but a specific input at runtime makes an operation impossible to complete — and unlike a logical error, Python notices and raises an object describing exactly what went wrong. This is the only category try/except can act on, and it's the subject of the rest of this chapter:

In [3]7 lines
def average(nums):
    return sum(nums) / len(nums)   # correct formula, but len(nums) can be 0

try:
    print(average([]))
except ZeroDivisionError as e:
    print("ZeroDivisionError:", e)   # ZeroDivisionError: division by zero -> caught and recoverable
ZeroDivisionError: division by zero

In practice — an API client retrying a failed request. Catching requests.exceptions.Timeout or ConnectionError around an HTTP call and retrying (often with a short delay) is one of the most common real uses of try/except in backend code — networks fail transiently far more often than most other operations, and a client that doesn't handle this ends up far less reliable than one that does.

7.2 Handling Syntax and Semantics

try/except lets a program catch a runtime error and recover from it instead of crashing outright. If nothing catches an exception, it keeps propagating upward — out of the function that raised it, out of whatever called that function, and so on — until either something catches it or it reaches the top and stops the program (7.3 covers this propagation in depth).

In [4]4 lines
try:
    x = 10 / 0
except ZeroDivisionError as e:
    print("Caught:", e)   # Caught: division by zero -> the crash was intercepted
Caught: division by zero

Multiple except blocks — a single try can be followed by several except blocks, each targeting a different exception type. Python checks them top to bottom and runs only the first one that matches:

In [5]11 lines
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
    except TypeError:
        print("Both arguments must be numbers")

safe_divide(10, 0)     # Cannot divide by zero
safe_divide(10, "a")     # Both arguments must be numbers
print(safe_divide(10, 2))   # 5.0 -> no exception at all, the return value comes straight through
Cannot divide by zero
Both arguments must be numbers
5.0

Common mistake — order matters. ZeroDivisionError is a subclass of Exception (7.4 covers the full hierarchy), so a broad except Exception listed first matches before a narrower one below it ever gets a chance — the specific block becomes dead code:

In [6]6 lines
try:
    1 / 0
except Exception:
    print("Caught by broad Exception")          # this one wins
except ZeroDivisionError:
    print("Caught by specific ZeroDivisionError")   # unreachable — never runs
Caught by broad Exception

Several exception types can also share one except block, by grouping them in a tuple (5.5):

In [7]9 lines
def parse_and_divide(a, b):
    try:
        return int(a) / int(b)
    except (ValueError, ZeroDivisionError) as e:   # either type lands here
        print(f"{type(e).__name__}: {e}")

parse_and_divide("x", 2)     # ValueError: invalid literal for int() with base 10: 'x'
parse_and_divide(10, 0)        # ZeroDivisionError: division by zero
print(parse_and_divide(10, 2))   # 5.0
ValueError: invalid literal for int() with base 10: 'x'
ZeroDivisionError: division by zero
5.0

try/except/else — 4.4 already introduced this: else runs only if the try block completed with no exception raised at all. Another example, this time looking something up in a dict (5.4):

In [8]10 lines
def get_score(scores, name):
    try:
        value = scores[name]
    except KeyError:
        print(f"{name} not found")
    else:
        print(f"{name} scored {value}")   # only runs if the lookup above didn't raise

get_score({"alice": 90}, "alice")   # alice scored 90
get_score({"alice": 90}, "bob")       # bob not found
alice scored 90
bob not found

finally — also introduced in 4.4: it runs no matter what. That includes when the try or except block contains a return — finally still runs before the function actually hands control back to the caller:

In [9]17 lines
def f():
    try:
        return 1
    finally:
        print("cleanup runs even though we're already returning")

print(f())   # cleanup runs even though we're already returning / 1

def g():
    try:
        raise ValueError("boom")
    except ValueError:
        return "handled"
    finally:
        print("g's cleanup always runs too")

print(g())   # g's cleanup always runs too / handled
cleanup runs even though we're already returning
1
g's cleanup always runs too
handled

Common mistake — the example above is relatively benign: f()'s return 1 still wins over finally's print(). But a return inside finally itself is far more dangerous — it doesn't just run alongside a pending exception, it silently replaces it. The ValueError below is raised, but never propagates anywhere, and nothing ever sees it — no traceback, no except block, nothing:

In [10]7 lines
def sneaky():
    try:
        raise ValueError("boom")
    finally:
        return "oops"   # this return swallows the exception entirely

print(sneaky())   # oops -> the ValueError never makes it out; it's simply gone, uncaught anywhere
oops

The structural rules — not every combination of these four keywords is legal. The valid order is always try → except (one or more) → else → finally, and:

Rule Statement
1 try must be followed by except, finally, or both
2 a bare try with none of except/else/finally is a SyntaxError
3 else is optional, but try/else with no except at all is invalid
4 try can be followed by multiple except blocks
5 else without at least one except is invalid (same rule as 3, from the other direction)
6 try/except/else/finally can be nested inside any of their own blocks

try itself is always mandatory; beyond that, what's actually required is at least one except, or a finally, or both — else is never enough on its own. Confirmed directly against the parser:

In [11]11 lines
# compile() just parses the code without running it — enough to prove/disprove validity
# without actually executing a SyntaxError-laden statement at the top level of a cell
try:
    compile("try:\n    pass\n", "<test>", "exec")   # try with NOTHING after it
except SyntaxError as e:
    print("try alone -> SyntaxError:", e)

try:
    compile("try:\n    pass\nelse:\n    pass\n", "<test>", "exec")   # try/else, no except
except SyntaxError as e:
    print("try + else (no except) -> SyntaxError:", e)
try alone -> SyntaxError: expected 'except' or 'finally' block (<test>, line 2)
try + else (no except) -> SyntaxError: expected 'except' or 'finally' block (<test>, line 3)

finally on its own — with no except at all — is perfectly valid, since rule 1 only needs except or finally. Likewise, except/else with no finally is fine too:

In [12]5 lines
compile("try:\n    pass\nfinally:\n    pass\n", "<test>", "exec")
print("try + finally, no except at all -> compiles fine")

compile("try:\n    pass\nexcept Exception:\n    pass\nelse:\n    pass\n", "<test>", "exec")
print("try + except + else, no finally -> compiles fine")
try + finally, no except at all -> compiles fine
try + except + else, no finally -> compiles fine

Nesting — a full try/except/else/finally can be placed inside any block of an outer one (its try, an except, the else, or the finally). Each level handles whatever it can; anything it doesn't catch keeps propagating out to the level above:

In [13]17 lines
def outer_op(a, b, c):
    try:
        result = a / b
        try:                                   # nested try, inside the outer try
            result = result / c
        except ZeroDivisionError:
            print("inner: cannot divide by c=0")
    except ZeroDivisionError:
        print("outer: cannot divide by b=0")
    else:
        print("no exceptions at all, result =", result)
    finally:
        print("outer_op finished")

outer_op(10, 2, 0)   # inner: cannot divide by c=0 / outer_op finished
outer_op(10, 0, 5)   # outer: cannot divide by b=0 / outer_op finished
outer_op(10, 2, 5)   # no exceptions at all, result = 1.0 / outer_op finished
inner: cannot divide by c=0
no exceptions at all, result = 5.0
outer_op finished
outer: cannot divide by b=0
outer_op finished
no exceptions at all, result = 1.0
outer_op finished

7.3 Raising and Re-raising

Everything so far reacted to exceptions the interpreter raised on its own (division by zero, a bad index, ...). raise lets your own code signal a problem the same way — useful the moment a function detects a rule violation that isn't already a built-in error (7.5 covers wrapping this in a custom exception type):

In [14]9 lines
def set_age(age):
    if age < 0:
        raise ValueError(f"age cannot be negative: {age}")   # explicitly signal the problem
    return age

try:
    set_age(-1)
except ValueError as e:
    print("caught:", e)   # caught: age cannot be negative: -1
caught: age cannot be negative: -1

Re-raising — sometimes an except block wants to react to an exception (log it, clean something up) without actually handling it, and let it keep propagating to whoever called this code. A bare raise — with no exception after it — inside an except block does exactly that: it re-raises the exception currently being handled, unchanged:

In [15]13 lines
def process(x):
    try:
        return 10 / x
    except ZeroDivisionError:
        print("logging: division by zero happened")   # react, but don't swallow it
        raise   # bare raise -> re-raises the SAME exception, unchanged

try:
    process(0)
except ZeroDivisionError as e:
    print("caller caught it too:", e)
    # logging: division by zero happened
    # caller caught it too: division by zero
logging: division by zero happened
caller caught it too: division by zero

Note — the print("logging: ...") line above stands in for real logging, kept as a plain print() so this example doesn't depend on the logging module. In real code, logging.exception(...) or logging.error(..., exc_info=True) (8 covers the logging module), called from inside an except block, is what actually captures and records a full traceback in a log — not a one-line print() like this.

A quick comparison before the three variants below — each one wires up exception chaining slightly differently:

Variant How it happens Attribute set Traceback wording
Implicit chaining a new exception is raised while an except block is still handling another __context__ "During handling of the above exception, another exception occurred"
Explicit chaining (raise ... from e) deliberately wrapping one exception in another __cause__ (and __context__ too) "The above exception was the direct cause of the following exception"
Suppressed chaining (raise ... from None) deliberately hiding the original exception __cause__ is None, __suppress_context__ is True the original exception is omitted from the traceback entirely

Implicit chaining — if a new, different exception is raised while an except block is still handling one, Python doesn't discard the original. It attaches it to the new exception's __context__, and an uncaught traceback shows both, joined by "During handling of the above exception, another exception occurred":

In [16]17 lines
import traceback

try:
    try:
        1 / 0
    except ZeroDivisionError:
        int("x")   # a different, unrelated exception raised while handling the first
except ValueError as e:
    print(e.__context__)   # division by zero -> the original exception, kept automatically

    # reconstructing what an uncaught traceback would print:
    print("".join(traceback.format_exception_only(type(e.__context__), e.__context__)).strip())
    print("During handling of the above exception, another exception occurred:")
    print("".join(traceback.format_exception_only(type(e), e)).strip())
    # ZeroDivisionError: division by zero
    # During handling of the above exception, another exception occurred:
    # ValueError: invalid literal for int() with base 10: 'x'
division by zero
ZeroDivisionError: division by zero
During handling of the above exception, another exception occurred:
ValueError: invalid literal for int() with base 10: 'x'

Explicit chaining — raise ... from ... — this is the deliberate version of the same idea: wrapping a low-level exception in a more meaningful custom one (7.5), while recording why. It sets __cause__ instead of __context__, and the traceback wording changes to "The above exception was the direct cause of the following exception":

In [17]14 lines
class ConfigError(Exception):
    pass

def load_config(raw_value):
    try:
        return int(raw_value)
    except ValueError as e:
        raise ConfigError("config value must be an integer") from e   # explicit cause

try:
    load_config("not-a-number")
except ConfigError as e:
    print("caught:", e)                      # caught: config value must be an integer
    print("caused by:", repr(e.__cause__))   # caused by: ValueError("invalid literal for int() with base 10: 'not-a-number'")
caught: config value must be an integer
caused by: ValueError("invalid literal for int() with base 10: 'not-a-number'")

Suppressing chaining — raise ... from None — when the original exception is internal noise a caller shouldn't need to see (an implementation detail, not useful context), from None drops it entirely instead of just hiding it:

In [18]12 lines
def load_config_quiet(raw_value):
    try:
        return int(raw_value)
    except ValueError:
        raise ConfigError("config value must be an integer") from None   # suppress the cause

try:
    load_config_quiet("not-a-number")
except ConfigError as e:
    print("caught:", e)                          # caught: config value must be an integer
    print("cause:", e.__cause__)                   # cause: None
    print("suppressed:", e.__suppress_context__)   # suppressed: True -> traceback won't show the ValueError either
caught: config value must be an integer
cause: None
suppressed: True

7.4 The Exception Hierarchy

Every built-in exception is a class, and every one of them inherits from BaseException. Almost all of them actually inherit from its subclass Exception — the few direct BaseException subclasses (SystemExit, KeyboardInterrupt, GeneratorExit) are meant to signal the program shutting down, not everyday errors, so they're deliberately kept outside Exception — catching Exception never accidentally swallows a Ctrl+C.

A simplified view of the tree, showing the exceptions used earlier in this chapter:

BaseException
 ├── SystemExit, KeyboardInterrupt, GeneratorExit   (not errors — left alone)
 └── Exception
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── TypeError
      ├── ValueError
      ├── AttributeError
      ├── NameError
      ├── OSError
      │    └── FileNotFoundError
      └── RuntimeError
           └── RecursionError
In [19]4 lines
print(issubclass(ZeroDivisionError, ArithmeticError))   # True
print(issubclass(ArithmeticError, Exception))              # True
print(issubclass(Exception, BaseException))                 # True
print(issubclass(KeyboardInterrupt, Exception))              # False -> deliberately outside Exception
True
True
True
False

Because except matches by class or any of its subclasses, catching a base class like ArithmeticError also catches every specific error under it — including ones raised by code you haven't seen:

In [20]4 lines
try:
    1 / 0   # raises ZeroDivisionError specifically
except ArithmeticError as e:   # catches it anyway, since it's a subclass
    print("caught via ArithmeticError:", type(e).__name__)   # caught via ArithmeticError: ZeroDivisionError
caught via ArithmeticError: ZeroDivisionError

Common built-in exceptions — a quick reference for the ones you'll hit constantly:

Exception Raised when
ZeroDivisionError dividing by zero
ValueError right type, but an invalid value (e.g. int("x"))
IndexError sequence index out of range
KeyError dict lookup with a missing key
AttributeError accessing an attribute/method that doesn't exist on the object
TypeError operation applied to an object of the wrong type
In [21]19 lines
try:
    [1, 2, 3][5]
except IndexError as e:
    print("IndexError:", e)   # IndexError: list index out of range

try:
    {"a": 1}["b"]
except KeyError as e:
    print("KeyError:", e)   # KeyError: 'b'

try:
    None.upper()
except AttributeError as e:
    print("AttributeError:", e)   # AttributeError: 'NoneType' object has no attribute 'upper'

try:
    "2" + 2
except TypeError as e:
    print("TypeError:", e)   # TypeError: can only concatenate str (not "int") to str
IndexError: list index out of range
KeyError: 'b'
AttributeError: 'NoneType' object has no attribute 'upper'
TypeError: can only concatenate str (not "int") to str

Common mistake — a bare except: (no exception type at all) matches BaseException, not Exception. That means it also swallows SystemExit and KeyboardInterrupt — so sys.exit() silently fails to exit, and Ctrl+C can't stop the program either. Always write except Exception: (or a specific type) instead of a bare except::

In [22]4 lines
try:
    raise SystemExit   # normally this would end the program
except:                  # bare except -> catches it anyway
    print("bare except caught SystemExit too")   # bare except caught SystemExit too
bare except caught SystemExit too

7.5 Custom Exceptions

Built-in exceptions only go so far — ValueError doesn't tell a caller which rule was violated, and catching one from your own code can't be told apart from one raised by a library doing something unrelated. A custom exception fixes both: it names the failure precisely, and callers can catch it specifically without touching everything else that happens to also raise ValueError.

Defining one is just subclassing Exception — no body needed at all:

In [23]12 lines
class NegativeAgeError(Exception):
    pass   # the name alone is the whole point — no extra behavior needed

def set_age(age):
    if age < 0:
        raise NegativeAgeError(f"age cannot be negative: {age}")
    return age

try:
    set_age(-5)
except NegativeAgeError as e:
    print("NegativeAgeError:", e)   # NegativeAgeError: age cannot be negative: -5
NegativeAgeError: age cannot be negative: -5

A custom exception can also override __init__ to carry structured data along with it — not just a message string, but attributes the except block can read back out:

In [24]16 lines
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw {amount}, only {balance} available")

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    withdraw(100, 150)
except InsufficientFundsError as e:
    print(e)                                # cannot withdraw 150, only 100 available
    print("short by", e.amount - e.balance)   # short by 50 -> extra attributes, not just text
cannot withdraw 150, only 100 available
short by 50

Custom exception hierarchies — just as the built-ins form a tree (7.4), your own exceptions can too. Define one base class for "anything that can go wrong in this app," then specific subclasses under it — callers can catch broadly via the base, or narrowly via a specific subclass, exactly like with built-in exceptions:

In [25]21 lines
class AppError(Exception):
    pass   # base class for every error this app raises on purpose

class ValidationError(AppError):
    pass

class NotFoundError(AppError):
    pass

def lookup(record_id, valid):
    if not valid:
        raise ValidationError(f"invalid id: {record_id}")
    raise NotFoundError(f"no record for id: {record_id}")

for record_id, valid in [(-1, False), (42, True)]:
    try:
        lookup(record_id, valid)
    except AppError as e:   # catches ValidationError and NotFoundError both
        print(f"{type(e).__name__}: {e}")
        # ValidationError: invalid id: -1
        # NotFoundError: no record for id: 42
ValidationError: invalid id: -1
NotFoundError: no record for id: 42

The real payoff of a hierarchy — an API boundary usually wants to catch broadly (turn any application error into a generic error response) while code closer to a specific failure might want to react differently to just one subclass. Catching AppError covers ValidationError and NotFoundError alike with one handler, while catching NotFoundError specifically lets that one case produce a distinct outcome (e.g. a 404 instead of a generic 500) — order matters here exactly as in 7.2, the specific except has to come before the broader one:

In practice — this is exactly how real libraries structure their exceptions. The requests library defines requests.exceptions.RequestException as a base class with specific subclasses (Timeout, ConnectionError, HTTPError, ...) underneath it — the same AppError/ValidationError/NotFoundError shape used here, so callers can catch broadly (except RequestException) or narrowly (except Timeout) depending on what they actually need to react to differently.

In [26]10 lines
def api_handler(record_id, valid):
    try:
        lookup(record_id, valid)
    except NotFoundError as e:
        return {"status": 404, "error": str(e)}   # a distinct outcome for this ONE specific case
    except AppError as e:
        return {"status": 500, "error": str(e)}   # every OTHER application error, handled generically

print(api_handler(42, True))    # {'status': 404, 'error': 'no record for id: 42'}
print(api_handler(-1, False))     # {'status': 500, 'error': 'invalid id: -1'}
{'status': 404, 'error': 'no record for id: 42'}
{'status': 500, 'error': 'invalid id: -1'}

7.6 Exception Handling Practices

Catch specific exceptions, not everything blindly. A bare except: (7.4) or an overly broad except Exception: can hide bugs that have nothing to do with what you were actually guarding against. Catch only the exception type(s) the risky line can actually raise.

Never silently swallow an exception. An empty except: pass makes a failure vanish without a trace — the program looks fine, but something inside it quietly broke:

In [27]9 lines
def risky():
    raise ValueError("bad input")

try:
    risky()
except Exception:
    pass   # bug disappears without a trace — at minimum, log or print it

print("program continues, nobody knows risky() failed")   # program continues, nobody knows risky() failed
program continues, nobody knows risky() failed

In practice — a real production incident shape. A bare except: pass (or except Exception: pass) inside a background worker or scheduled job is a genuinely common way real incidents happen: the job keeps "succeeding" from the outside while silently failing to do its actual work — sending emails, syncing data, processing payments — sometimes for weeks, until someone notices the downstream effect rather than the failure itself.

Keep the try block as small as possible. Wrap only the line(s) that can actually raise, so an unrelated bug elsewhere in the same block doesn't get mistakenly caught and misreported as "bad data":

In [28]7 lines
data = {"count": "5"}
try:
    n = int(data["count"])   # only the conversion can actually fail here
except (KeyError, ValueError) as e:
    print("bad data:", e)

print(n * 2)   # 10 -> the rest of the logic stays outside try, so it's obviously not the risky part
10

EAFP vs. LBYL. Python idiom generally favors EAFP ("Easier to Ask Forgiveness than Permission" — try it, handle the failure) over LBYL ("Look Before You Leap" — check first, then act). LBYL can also race: the condition can become false between the check and the action; EAFP has no such gap because the check and the action are the same step:

In [29]13 lines
d = {"a": 1}

# LBYL - check membership first, then access
if "b" in d:
    print(d["b"])
else:
    print("b missing (LBYL)")   # b missing (LBYL)

# EAFP - just try the access, handle the failure
try:
    print(d["b"])
except KeyError:
    print("b missing (EAFP)")   # b missing (EAFP)
b missing (LBYL)
b missing (EAFP)

A real race, not just a sequential example — the dict lookup above is deterministic (nothing else touches d between the check and the access), so it can't actually race. A genuine TOCTOU ("time-of-check-to-time-of-use") race needs something that can change out from under you between the check and the action — a file another process deletes is the classic case. The gap between os.path.exists(path) and open(path) is exactly that gap; here it's forced deliberately, standing in for what another process could do at that same moment in real code:

In [30]13 lines
import os

path = "/tmp/toctou_demo.txt"
with open(path, "w") as f:
    f.write("hello")

if os.path.exists(path):          # CHECK: passes, the file exists right now...
    os.remove(path)                 # ...simulating another process deleting it in that exact gap
    try:
        with open(path) as f:         # USE: the check above is already stale by the time this runs
            print(f.read())
    except FileNotFoundError as e:
        print("FileNotFoundError:", e)   # the file existed a moment ago -> the check gave no guarantee
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/toctou_demo.txt'

Always release resources, even on failure. If a function opens something (a file, a lock, a connection) and then raises partway through, that resource still has to be cleaned up — finally (7.2) guarantees the cleanup line runs whether the body succeeded or not:

In [31]12 lines
def process_file(path):
    f = open(path)
    try:
        raise ValueError("something went wrong while using the file")
    finally:
        f.close()
        print("file closed no matter what")   # file closed no matter what

try:
    process_file("/etc/hostname")
except ValueError as e:
    print("caught:", e)   # caught: something went wrong while using the file
file closed no matter what
caught: something went wrong while using the file

In real code, prefer with — the manual try/finally above demonstrates the underlying mechanics, but idiomatic Python would open the file with with open(path) as f: instead (16.6 covers the with statement / context managers in depth). It calls f.close() automatically, even on an exception, with no explicit finally block needed at all.

7.7 Exception Groups (except*)

Every exception so far has been one object, and a try can only be in the middle of handling one at a time. Some operations, though, can genuinely fail in several, independent ways at once — a batch job where 3 of 10 records each raise a different error, or several concurrently-running tasks (17.6) each failing on their own. ExceptionGroup is a container that bundles multiple exceptions together as a single object that can itself be raised and caught, and except* is the matching syntax to catch and react to the specific exception types bundled inside it, individually.

In [32]5 lines
try:
    raise ExceptionGroup("multiple failures", [ValueError("bad value"), TypeError("bad type")])
except Exception as e:
    print(type(e).__name__, e.exceptions)
# ExceptionGroup (ValueError('bad value'), TypeError('bad type'))
ExceptionGroup (ValueError('bad value'), TypeError('bad type'))

An ExceptionGroup can still be caught with a plain except (as above) — it's an ordinary exception object underneath, with a .message and an .exceptions tuple. The real point of except* is reacting to each kind of exception inside the group separately, instead of getting the whole bundle at once:

In [33]11 lines
try:
    raise ExceptionGroup(
        "multiple failures",
        [ValueError("bad value"), TypeError("bad type"), ValueError("another bad value")],
    )
except* ValueError as eg:
    print("caught ValueErrors:", eg.exceptions)
except* TypeError as eg:
    print("caught TypeErrors:", eg.exceptions)
# caught ValueErrors: (ValueError('bad value'), ValueError('another bad value'))
# caught TypeErrors: (TypeError('bad type'),)
caught ValueErrors: (ValueError('bad value'), ValueError('another bad value'))
caught TypeErrors: (TypeError('bad type'),)

Each except* clause runs at most once, receiving a sub-group containing only the exceptions that matched its type — eg above is itself a smaller ExceptionGroup, not a single exception. Any exception in the original group that no except* clause claims keeps propagating upward afterward, exactly like an unmatched exception would with plain except:

In [34]9 lines
try:
    try:
        raise ExceptionGroup("mixed", [ValueError("v"), KeyError("k")])
    except* ValueError as eg:
        print("handled ValueError:", eg.exceptions)
except* KeyError as remaining:
    print("still propagating:", remaining.exceptions)
# handled ValueError: (ValueError('v'),)
# still propagating: (KeyError('k'),)
handled ValueError: (ValueError('v'),)
still propagating: (KeyError('k'),)

Common mistake — mixing plain except and except* on the same try. A single try statement must use one style or the other consistently; combining them is a SyntaxError, caught before the code even runs:

In [35]12 lines
bad_code = """
try:
    pass
except ValueError:
    pass
except* TypeError:
    pass
"""
try:
    compile(bad_code, "<test>", "exec")
except SyntaxError as e:
    print("SyntaxError:", e)   # cannot have both 'except' and 'except*' on the same 'try' 
SyntaxError: cannot have both 'except' and 'except*' on the same 'try' (<test>, line 6)

In practice — asyncio.TaskGroup. asyncio.TaskGroup (17.6) is the modern way to run several coroutines together: if more than one of its child tasks fails, it raises a single ExceptionGroup bundling all of them, instead of raising only the first failure and silently discarding the rest — except* is the tool built specifically to handle exactly this multi-task-failure case, reacting to each kind of failure on its own:

In [36]21 lines
import asyncio

async def fail_a():
    raise ValueError("task A failed")

async def fail_b():
    raise TypeError("task B failed")

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fail_a())
            tg.create_task(fail_b())
    except* ValueError as eg:
        print("ValueError group:", eg.exceptions)
    except* TypeError as eg:
        print("TypeError group:", eg.exceptions)

asyncio.run(main())
# ValueError group: (ValueError('task A failed'),)
# TypeError group: (TypeError('task B failed'),)
ValueError group: (ValueError('task A failed'),)
TypeError group: (TypeError('task B failed'),)

7.9 Additional Exception Handling Concepts From Notes

Overflow bucket for this chapter — small or unclassified items that clearly belong to this domain but not yet to a specific section above.

The assert statement — a sanity check, not general error handling: assert condition, message raises AssertionError(message) if condition is falsy. It's meant for catching programmer mistakes (an invariant that should never be false) during development, not for validating user input — asserts can be stripped out entirely when Python runs with the -O optimization flag, so input validation must never rely on them:

In [37]10 lines
def set_temperature(celsius):
    assert celsius >= -273.15, f"temperature below absolute zero: {celsius}"
    return celsius

print(set_temperature(20))   # 20

try:
    set_temperature(-300)
except AssertionError as e:
    print("AssertionError:", e)   # AssertionError: temperature below absolute zero: -300
20
AssertionError: temperature below absolute zero: -300

In practice — a sanity check in a data pipeline. A data-processing script asserting that a loaded DataFrame has the expected columns, or that a batch has the expected row count, before running expensive downstream computation on it, is a common real use of assert as a fail-fast guard — distinct from validating untrusted external input (20.1), which needs a real, non-strippable check instead.

Inside the exception object — every exception stores its constructor arguments in .args. str(e) renders it for humans (just the message), while repr(e) renders it as Python-like code, including the class name — useful when logging, since it says which exception type it was, not just its message:

In [38]6 lines
try:
    raise ValueError("bad value", 42)
except ValueError as e:
    print(e.args)   # ('bad value', 42)
    print(str(e))     # ('bad value', 42) -> str() of a multi-arg exception is its args tuple
    print(repr(e))   # ValueError('bad value', 42) -> includes the class name
('bad value', 42)
('bad value', 42)
ValueError('bad value', 42)