python · level 7

Errors & Exceptions

try / except / else / finally + EAFP.

100 XP

Python Errors & Exceptions

Python uses exceptions for failures. Even normal control flow (a for loop reaching the end) is implemented as a raised StopIteration under the hood.

try / except / else / finally

try:
    user = fetch_user(id)
except UserNotFound:
    user = None
except (TimeoutError, ConnectionError) as e:
    log.warning("network failure", e)
    user = None
else:
    # runs only if `try` completed without raising
    log.info("found user", id=user.id)
finally:
    # always runs
    close_session()

else is the rarely-used fourth block: runs only when the try body finished without raising. Useful for code that should only run on success but doesn't belong inside try (because you don't want to catch its exceptions too).

Catching the right thing

Exception classes form a hierarchy:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 └── Exception
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── ValueError
      ├── TypeError
      ├── OSError
      │    ├── FileNotFoundError
      │    └── PermissionError
      └── RuntimeError

Catch the most specific class you can. except KeyError is precise. except Exception catches almost everything (a pretty wide net). except BaseException even catches KeyboardInterrupt — so don't.

Raising

raise ValueError("expected non-empty")
raise ValueError(f"bad input: {x!r}")

In an except block, raise with no argument re-raises the current exception:

try:
    risky()
except SpecificError as e:
    log.warning("retrying", e)
    if retries_left:
        retry()
    else:
        raise              # bubble out unchanged

Custom exceptions

Subclass Exception (not BaseException):

class PaymentError(Exception):
    pass

class InsufficientFunds(PaymentError):
    def __init__(self, requested, balance):
        super().__init__(f"requested {requested}, balance {balance}")
        self.requested = requested
        self.balance = balance

Now callers can catch PaymentError (anything payment-related) or specifically InsufficientFunds.

Exception chaining

Re-raising while preserving context:

try:
    raw = api.fetch()
except APIError as e:
    raise ProcessingError("could not load data") from e

The from e produces a "during handling of the above exception, another exception occurred" traceback that shows both. Helpful for debugging.

Context managers (with)

The with statement guarantees cleanup. Use it for files, locks, network connections, transactions:

with open("config.toml") as f:
    data = f.read()
# f.close() is called even if read() raises

with lock:
    do_critical_thing()
# lock.release() is called even if it raises

Roll your own with __enter__ / __exit__ or @contextmanager:

from contextlib import contextmanager

@contextmanager
def timed(label):
    start = time.time()
    try:
        yield
    finally:
        elapsed = time.time() - start
        log.info(f"{label}: {elapsed:.2f}s")

with timed("query"):
    rows = db.execute(...)

EAFP vs LBYL

Two styles of error handling:

  • EAFP ("Easier to Ask Forgiveness than Permission") — try the operation, catch the failure.
  • LBYL ("Look Before You Leap") — check, then try.
# EAFP — Pythonic.
try:
    return d[key]
except KeyError:
    return default

# LBYL.
if key in d:
    return d[key]
return default

For simple lookups, prefer the in-language helper: d.get(key, default). For everything else, EAFP wins in Python — fewer races, less duplication.

Don't swallow

The single biggest mistake:

try:
    risky()
except:                # bare except = catches EVERYTHING including KeyboardInterrupt
    pass               # silently

You will spend hours debugging the bug this hides. The bare except: catches KeyboardInterrupt (so Ctrl-C stops working) and SystemExit (so sys.exit() stops working). At minimum write except Exception:. Better: catch the specific type you expect.