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.
# 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:
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:
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).
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:
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:
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):
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):
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:
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:
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:
# 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:
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:
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):
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:
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":
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":
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:
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
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:
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 |
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::
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:
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:
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:
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.
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:
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":
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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)