# Exception Handling

## 1. `try`/`except` Basics

```python
try:
    result = 10 / 0
except ZeroDivisionError:
    print("can't divide by zero")
```

`try` runs its block; if an exception is raised, Python stops executing the `try` block immediately and looks for a matching `except` clause. If no exception occurs, every `except` clause is skipped entirely.

## 2. The Exception Hierarchy

Every exception is a class, and `except` matches by **class, including subclasses**:

```python
try:
    [1, 2][5]
except LookupError:       # IndexError IS-A LookupError, so this catches it
    print("bad lookup")
```

```
BaseException
 ├── SystemExit, KeyboardInterrupt, GeneratorExit   (NOT caught by `except Exception`)
 └── Exception
      ├── ArithmeticError → ZeroDivisionError, OverflowError
      ├── LookupError → IndexError, KeyError
      ├── ValueError, TypeError, AttributeError, ...
      └── ... (your custom exceptions typically subclass Exception)
```

`except Exception:` deliberately does **not** catch `SystemExit`/`KeyboardInterrupt`/`GeneratorExit` — they subclass `BaseException` directly, specifically so that `Ctrl+C` and `sys.exit()` aren't accidentally swallowed by a broad `except Exception` handler.

## 3. Catching Multiple Types, and Binding the Exception

```python
try:
    risky()
except (ValueError, TypeError) as e:   # tuple = match any of these
    print(f"bad input: {e}")
except Exception as e:                   # broader catch-all, checked only if the above didn't match
    print(f"unexpected: {e}")
```

`except` clauses are checked **top to bottom**, and only the *first* matching one runs — order matters, and a broad exception type listed before a narrower one will shadow it, so put specific exceptions first.

## 4. `else` and `finally`

```python
try:
    value = int(user_input)
except ValueError:
    print("not a number")
else:
    print(f"parsed: {value}")   # runs ONLY if the try block raised nothing
finally:
    print("done")                 # ALWAYS runs — exception or not, return or not
```

`else` separates "code that might raise" from "code that should only run on success" — it's not required, but makes intent clearer than putting success-only code at the end of the `try` block (where it could accidentally catch its own exceptions). `finally` runs unconditionally, even if the `try`/`except` block returns, breaks, or continues — it is exactly the place for cleanup that must always happen (see also the Context Managers topic, which formalizes this pattern for resources).

## 5. Raising and Re-raising

```python
def validate(age):
    if age < 0:
        raise ValueError(f"age cannot be negative: {age}")

try:
    validate(-5)
except ValueError:
    print("logging the error...")
    raise   # bare `raise` re-raises the SAME exception, with its original traceback intact
```

`raise` (no argument) inside an `except` block re-raises the exception currently being handled, preserving its original traceback — different from `raise ValueError(...)` again, which would start a brand-new traceback from this point.

## 6. Exception Chaining: `raise ... from`

```python
try:
    int("not a number")
except ValueError as e:
    raise RuntimeError("config parsing failed") from e
```

```
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: 'not a number'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
RuntimeError: config parsing failed
```

`raise ... from e` explicitly links the new exception's `__cause__` to `e`, producing the "direct cause" message above. Without `from`, raising a new exception *inside* an `except` block still chains it automatically via `__context__` ("during handling of the above exception, another exception occurred") — `from` upgrades an implicit, incidental link into an explicit, intentional one; `raise ... from None` suppresses the chain display entirely when the original exception is truly irrelevant to report.

## 7. Custom Exceptions

```python
class InsufficientFundsError(Exception):
    def __init__(self, requested, available):
        super().__init__(f"requested {requested}, only {available} available")
        self.requested = requested
        self.available = available

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

try:
    withdraw(50, 100)
except InsufficientFundsError as e:
    print(e.requested, e.available)   # structured data, not just a message string
```

Subclassing `Exception` (not `BaseException`) and calling `super().__init__(message)` gives you a normal, catchable exception whose `str(e)` is the message, while still letting you attach structured attributes (`requested`, `available`) that calling code can inspect programmatically instead of parsing an error string.

## 8. Under the Hood: Stack Unwinding, Tracebacks, and Where `finally` Fits In

### An exception is an object that travels up the call stack, frame by frame

When `raise` executes, CPython doesn't search for a handler by scanning source code — it walks **up the call stack, one frame at a time**, checking each frame for an active `try` block whose `except` matches the exception's type. Each frame it passes through without a match is popped (a process called **unwinding**), and a reference to that frame's location (file, line, function name) is prepended to the exception's **traceback** — which is exactly why a traceback reads top-to-bottom as "outermost call first, deepest failure last": it's built by accumulating frames during the unwind, most-recently-added frame last.

```python
def c():
    raise ValueError("boom")   # traceback frame 3 (deepest)

def b():
    c()                          # traceback frame 2

def a():
    b()                          # traceback frame 1

a()
```

If no frame all the way up to the module level has a matching `except`, the exception reaches the **interpreter's top level**, which prints the accumulated traceback and terminates the process (or, in a REPL, just prints it and continues).

![raise ValueError in c() unwinds through b() and a(), prepending each frame's location, until module level's except ValueError matches](diagrams/01-stack-unwinding.svg)

### `finally` runs *during* the unwind, not after it

A `finally` block is guaranteed to execute even while an exception is actively propagating past its `try` — mechanically, CPython temporarily "parks" the in-flight exception, runs the `finally` block's bytecode, and then either re-raises the parked exception (if `finally` didn't itself return/raise) or lets whatever `finally` did (a `return`, a new `raise`) override the original outcome entirely:

```python
def f():
    try:
        raise ValueError("original")
    finally:
        return "overridden"   # SWALLOWS the ValueError entirely — no warning, no trace

print(f())  # "overridden" — the ValueError never propagates further
```

This is a genuine footgun precisely because it's silent: a `return`/`break`/`continue` inside `finally` discards any exception currently unwinding through it, with no indication that anything was suppressed.

### `sys.exc_info()` and why `except ... as e` scopes `e` to the block

Python maintains a notion of the "exception currently being handled" (accessible via `sys.exc_info()`), which is what makes bare `raise` and implicit `__context__` chaining possible without passing the exception object around explicitly. `except Exception as e:` additionally does something specific at the *end* of the block: CPython automatically `del`s the name `e` when the `except` block exits (successfully or not), specifically to avoid a reference cycle — the exception object's traceback holds a reference to every frame it passed through, and if one of those frames held a local variable pointing back at the exception, that's a reference cycle that only the cyclic garbage collector (not refcounting) would eventually clean up. This is why `e` is unexpectedly `NameError`-undefined immediately after an `except ... as e:` block, even though it looks like an ordinary local variable.

## 9. Common Pitfalls

1. **Bare `except:` (catching absolutely everything, including `KeyboardInterrupt`/`SystemExit`)** — this can make a script impossible to interrupt with Ctrl+C and hides genuine bugs; always catch a specific exception type, or at narrowest `except Exception:` if you truly need a catch-all.
2. **`return`/`break`/`continue` inside `finally` silently swallowing an in-flight exception** — as shown in Under the Hood, this discards the original exception with zero indication; avoid control-flow statements inside `finally` blocks.
3. **Using exceptions for expected, common control flow where a simple `if` would do** — exceptions in CPython are relatively cheap to raise/catch compared to some other languages, but a `try` wrapping a hot loop's every iteration for an expected, frequent condition is usually a sign the logic should be an `if` check instead.
4. **Catching a broad exception type before a narrower, more specific one** — `except Exception:` listed before `except ValueError:` makes the `ValueError` clause unreachable dead code; Python does not warn about this.
5. **Referencing `e` after the `except ... as e:` block has exited** — as covered in Under the Hood, the name is automatically deleted at block exit; assign `msg = str(e)` inside the block if you need the information afterward.
6. **Forgetting that `except (ValueError, TypeError):` requires a tuple** — `except ValueError, TypeError:` is Python 2 syntax and is a `SyntaxError` in Python 3; the parentheses are mandatory.

## 10. Quick Reference

```python
try:
    risky()
except (ValueError, TypeError) as e:   # catch multiple types; most specific first
    handle(e)
else:
    only_if_no_exception()
finally:
    always_runs()                        # cleanup — but avoid return/break/continue here

raise                                      # bare re-raise, inside except, preserves traceback
raise RuntimeError("x") from original       # explicit chain -> __cause__
raise RuntimeError("x") from None            # suppress chain display

class MyError(Exception): ...               # custom exception — subclass Exception, not BaseException

BaseException
 ├── SystemExit / KeyboardInterrupt / GeneratorExit   # NOT caught by `except Exception`
 └── Exception                                          # everything you should normally catch/raise
```
