Errors & Exceptions
try / except / else / finally + EAFP.
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.