Errors and exception handling
The roles of try/except/else/finally, raise and user-defined exceptions, and the ExceptionGroup and except* introduced in 3.11.
Chapter 5 Functions — argument patterns settled the expressive power of functions. This chapter is about handling failures in those functions — exception handling.
The ExceptionGroup + except* pair covered at the end of this chapter shows up again with TaskGroup in Chapter 14 Intro to async (asyncio) and Chapter 18 Async in depth. In async code, multiple tasks can fail at the same time, and that’s where ExceptionGroup comes in. Getting comfortable with the shape here makes Chapter 14 lighter.
Python expresses errors with exceptions. Unlike the error-code-return style of some other languages, the default is: throw on abnormal situations, and catch them on the receiving side. This is often called EAFP (Easier to Ask for Forgiveness than Permission).
The simplest try / except #
def divide(a: int, b: int) -> float:
try:
return a / b
except ZeroDivisionError:
return float("inf")
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # infSyntax:
- Run code that may raise inside the
try:block - Catch with
except ExceptionType: - Unhandled exceptions propagate upward (to the caller)
LBYL vs EAFP #
Other languages typically use “check before doing” (LBYL — Look Before You Leap).
if b != 0:
result = a / b
else:
result = float("inf")EAFP is more idiomatic in Python.
try:
result = a / b
except ZeroDivisionError:
result = float("inf")Reason: Python is dynamically typed, so “checking the condition precisely in advance” is often hard, and exception handling cost is low (unless exceptions happen frequently).
Catching multiple exceptions #
def parse_int(value: str) -> int | None:
try:
return int(value)
except (ValueError, TypeError):
return None
print(parse_int("42")) # 42
print(parse_int("abc")) # None
print(parse_int(None)) # NoneGroup with a (Exception1, Exception2) tuple to catch them together.
Separate when handling differently #
try:
do_work()
except ValueError as e:
print(f"bad value: {e}")
except KeyError as e:
print(f"missing key: {e}")
except Exception as e:
print(f"other: {e}")
raise # re-throwas e captures the exception object. You can pull out the message (str(e)) and the cause (e.__cause__).
else and finally
#
try has two additional clauses.
def read_file(path: str) -> str:
f = None
try:
f = open(path)
data = f.read()
except FileNotFoundError:
return ""
else:
# runs only when try ended without an exception
print(f"read ok: {len(data)} bytes")
return data
finally:
# always runs, regardless of whether an exception occurred
if f is not None:
f.close()else— only whentryended without an exception. For “do this only on success”finally— always, regardless of exceptions. For resource cleanup (closing files, releasing locks)
The above code can in fact be written more simply with with.
def read_file(path: str) -> str:
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
return ""with takes over the close responsibility of finally. Chapter 10 Context managers (with, contextlib) covers it in detail.
raise — throwing directly
#
def withdraw(balance: int, amount: int) -> int:
if amount <= 0:
raise ValueError("withdrawal must be positive")
if amount > balance:
raise ValueError(f"insufficient balance: {balance} < {amount}")
return balance - amountWrite an exception instance (or class) after raise. raise ValueError (class only) is also possible, but including a message like raise ValueError("...") is more common.
Re-raising #
When you handled inside except but want to notify the caller.
try:
do_work()
except KeyError:
log("key missing")
raise # bare raise — re-throws the caught exception as isA bare raise re-throws the currently caught exception upward. The trace is preserved.
raise ... from ... — chaining the cause
#
When you want to wrap the original exception in a different one.
def load_config(path: str) -> dict:
try:
with open(path) as f:
return parse(f.read())
except (OSError, ValueError) as e:
raise ConfigError(f"config load failed: {path}") from eWith from e, the traceback shows “the direct cause of this exception is e”. Useful when debugging.
Exception hierarchy #
Python exceptions are a class hierarchy. Catching the parent catches all children.
BaseException
├─ SystemExit ← sys.exit()
├─ KeyboardInterrupt ← Ctrl+C
└─ Exception ← typically catch only below this
├─ ValueError
│ └─ UnicodeError
├─ TypeError
├─ LookupError
│ ├─ KeyError
│ └─ IndexError
├─ ArithmeticError
│ └─ ZeroDivisionError
├─ OSError
│ └─ FileNotFoundError
└─ ...Important rule: In normal code, only catch up to except Exception. except BaseException or a bare except: swallows system signals like KeyboardInterrupt, leading to accidents where Ctrl+C stops working.
try:
risky()
except: # bare except — catches KeyboardInterrupt too
pass
# or
except BaseException: # same problem
passtry:
risky()
except Exception as e:
log(e)User-defined exceptions #
When building a library / module, defining your own exception classes is a good habit. Callers can specify exactly what they want to catch.
class AppError(Exception):
"""Base of all exceptions in this app."""
class ConfigError(AppError):
"""Configuration-related error."""
class DatabaseError(AppError):
"""DB-related error."""
class RowNotFoundError(DatabaseError):
"""The required row is missing."""
def __init__(self, table: str, key: str):
super().__init__(f"{key} not found in {table}")
self.table = table
self.key = keyCaller side:
try:
user = repo.get_user("u1")
except RowNotFoundError as e:
return Response(status=404)
except DatabaseError as e:
log_critical(e)
return Response(status=500)Each caller catches exactly what they want to catch.
The pitfall of finally — return precedence
#
A return in finally overrides the return in try. Almost an antipattern.
def f() -> int:
try:
return 1
finally:
return 2 # this wins
print(f()) # 2finally should only clean up side effects, and avoid return or raise where possible.
Exception Group — except* (3.11+)
#
When multiple tasks can fail simultaneously (asyncio’s TaskGroup, parallel work), a new mechanism for raising multiple exceptions at once was added. That’s ExceptionGroup, and the syntax to catch it is except*.
errors = ExceptionGroup(
"multiple tasks failed",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errorsexcept* picks out only exceptions of the matching type from the group and re-throws the rest.
try:
raise ExceptionGroup(
"multiple failures",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
except* ValueError as eg:
print("value errors:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
print("type errors:", [str(e) for e in eg.exceptions])When do you meet this? When running multiple tasks concurrently with asyncio.TaskGroup, if two or more fail, ExceptionGroup is automatically created. You’ll meet the pattern again in Chapter 14 Intro to async (asyncio).
assert — checking assumptions during development
#
def calc_average(items: list[int]) -> float:
assert len(items) > 0, "cannot average an empty list"
return sum(items) / len(items)assert condition, message — if the condition is False, AssertionError is raised.
Caveat: in python -O (optimization) mode, every assert is removed. So:
- ✅ Checking assumptions during development (such as “at this point it can never be None”)
- ❌ Validating user input — this must always be done with regular
if+raise
def serve(user: User | None):
# ✅ system assumption: the auth middleware should have blocked None
assert user is not None, "auth middleware missing?"
# ❌ user input must not be guarded with assert
# assert age >= 0, "negative age not allowed" ← disappears under -O
if age < 0:
raise ValueError("age must be >= 0")A sense for good exception handling #
Finally, patterns that come up often in code review.
1) Don’t catch too broadly #
try:
process(item)
except Exception:
pass # ignore every exception — hides bugstry:
process(item)
except ValueError as e:
log(f"bad item: {e}")2) Keep try blocks short #
try:
a = compute()
b = transform(a)
c = validate(b)
d = save(c)
return d
except SomeError:
...You don’t know which line raised SomeError. Wrap only the suspect line.
3) Be suspicious of except blocks that only pass
#
A bare except: pass is almost always the start of a bug. If you really can ignore it, leave a comment with the reason, or at least log it. Operational logging standards are covered in Chapter 31 Logging and observability.
4) Don’t use exceptions for normal flow #
Exceptions are for abnormal situations. Solving normal branches with try / except traps the reader. (Though, since Python is idiomatically EAFP, some cases are normal — like handling conversion failure in int("abc").)
Exercises #
- Write a function with signature
def safe_int(value: str) -> int | None:that triesint(value)and returnsNoneonValueError. Also catchTypeError(in case None is passed) by grouping into a tuple. - Build a user-defined exception hierarchy
AppError→ValidationError/NotFoundError. Writedef find_user(id: int) -> User:to raiseValidationErrorwhenid <= 0andNotFoundErrorwhen not found in the DB. Write caller code that handles each with a different message. - Create an
ExceptionGroupyourself. Collect the results of three tasks; if two or more areValueError, wrap them asExceptionGroup("batch failure", [...])and throw. The caller receives them withexcept* ValueErrorand prints the message list.
In one line: Python is EAFP — try and catch is the natural style.
try/except/else/finallyhave clearly distinct roles, and resource cleanup is usually cleaner withwith. Catch only up toexcept Exception; bare except andBaseExceptionare forbidden. User-defined exceptions form a hierarchy, andassertis for assumption checks only. TheExceptionGroup+except*of 3.11+ is for concurrent failures in async.
Next chapter #
In the next chapter, Chapter 7 Modules, packages, and pyproject.toml, we cover splitting code across multiple files — the import system, the difference between modules and packages, __init__.py, __main__, and pyproject.toml, all in one place. It is the last chapter of Part 1.