Contents
6 Chapter

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 #

basic 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))   # inf

Syntax:

  • 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).

LBYL style
if b != 0:
    result = a / b
else:
    result = float("inf")

EAFP is more idiomatic in Python.

EAFP style — Pythonic
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 #

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))   # None

Group with a (Exception1, Exception2) tuple to catch them together.

Separate when handling differently #

different handling per exception
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-throw

as 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.

else / finally
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 when try ended without an exception. For “do this only on success”
  • finallyalways, regardless of exceptions. For resource cleanup (closing files, releasing locks)

The above code can in fact be written more simply with with.

with — resource cleanup usually goes through this
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 #

raise
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 - amount

Write 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.

re-raise
try:
    do_work()
except KeyError:
    log("key missing")
    raise   # bare raise — re-throws the caught exception as is

A 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.

chaining the cause with from
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 e

With 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.

commonly seen hierarchy (simplified)
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.

❌ never
try:
    risky()
except:           # bare except — catches KeyboardInterrupt too
    pass

# or
except BaseException:   # same problem
    pass
✅ catch all normal exceptions
try:
    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.

user-defined exceptions
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 = key

Caller side:

caller 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.

🚫 confusing code
def f() -> int:
    try:
        return 1
    finally:
        return 2   # this wins

print(f())   # 2

finally 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*.

creating an ExceptionGroup
errors = ExceptionGroup(
    "multiple tasks failed",
    [ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errors

except* picks out only exceptions of the matching type from the group and re-throws the rest.

using except*
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 #

assert
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
assumption vs input validation
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 #

❌ too broad
try:
    process(item)
except Exception:
    pass    # ignore every exception — hides bugs
✅ catch only what you want
try:
    process(item)
except ValueError as e:
    log(f"bad item: {e}")

2) Keep try blocks short #

❌ too much in try
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 #

  1. Write a function with signature def safe_int(value: str) -> int | None: that tries int(value) and returns None on ValueError. Also catch TypeError (in case None is passed) by grouping into a tuple.
  2. Build a user-defined exception hierarchy AppErrorValidationError / NotFoundError. Write def find_user(id: int) -> User: to raise ValidationError when id <= 0 and NotFoundError when not found in the DB. Write caller code that handles each with a different message.
  3. Create an ExceptionGroup yourself. Collect the results of three tasks; if two or more are ValueError, wrap them as ExceptionGroup("batch failure", [...]) and throw. The caller receives them with except* ValueError and prints the message list.

In one line: Python is EAFP — try and catch is the natural style. try/except/else/finally have clearly distinct roles, and resource cleanup is usually cleaner with with. Catch only up to except Exception; bare except and BaseException are forbidden. User-defined exceptions form a hierarchy, and assert is for assumption checks only. The ExceptionGroup + 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.

X