7. Errors and exceptions
7.1 try, except, else, finally
Python reports errors by raising exceptions. An unhandled exception unwinds the stack, prints a traceback, and exits with status 1.
def read_port(text: str) -> int:
try:
port = int(text)
except ValueError:
raise ValueError(f"not a number: {text!r}") from None
else: # runs only if no exception
if not 0 < port < 65536:
raise ValueError(f"out of range: {port}")
return port
finally: # always runs: cleanup goes here
pass
for t in ["443", "http", "70000"]:
try:
print(read_port(t))
except ValueError as e:
print("error:", e)
Reading a traceback: read it bottom-up. The last line is the exception
type and message; the lines above are the call stack, innermost call last.
Since 3.11, tracebacks point at the exact sub-expression with ^^^^
markers, and since 3.13 they are coloured. Appendix A decodes the 15
exceptions you will see most.
7.2 The rules for handling exceptions
- Catch the narrowest exception you can handle.
except ValueError, notexcept Exception. - Never write a bare
except:. It also catchesKeyboardInterruptandSystemExit, so Ctrl-C stops working. - Do not swallow errors.
except Exception: passturns a crash into silently wrong data. If you catch broadly at a boundary (a request handler, a worker loop), log with the traceback (logger.exception). - Chain with
raise ... from errwhen you translate an exception, so the original cause stays in the traceback. - EAFP over LBYL. "Easier to ask forgiveness than permission": try the operation and handle failure, rather than checking first. Checking first is often a race, as the OS guide Ch 17 shows for files:
import os
# LBYL: racy, the file can vanish between the check and the open (TOCTOU)
if os.path.exists("config.toml"):
pass # open(...) might still fail here
# EAFP: one operation, one place to handle failure
try:
with open("config.toml", "rb") as f:
raw = f.read()
except FileNotFoundError:
raw = b""
7.3 The exception hierarchy
BaseException
├── SystemExit, KeyboardInterrupt, GeneratorExit <- not errors; don't catch these by accident
└── Exception <- catch-all for real errors
├── ValueError, TypeError, KeyError, IndexError, AttributeError
├── ArithmeticError → ZeroDivisionError, OverflowError
├── OSError → FileNotFoundError, PermissionError, ConnectionError, TimeoutError, ...
├── RuntimeError → RecursionError, NotImplementedError
└── ExceptionGroup (3.11+)
OSError carries errno, the same error number the kernel returned
(OS Ch 2):
import errno
try:
open("/root/secret")
except OSError as e:
print(e.errno == errno.EACCES or e.errno == errno.ENOENT, e.strerror)
7.4 Custom exceptions
Give your library one base exception, and specific subclasses under it:
class FetchError(Exception):
"""Base class for errors from this module."""
class RateLimited(FetchError):
def __init__(self, retry_after: float) -> None:
super().__init__(f"rate limited; retry after {retry_after}s")
self.retry_after = retry_after
try:
raise RateLimited(2.5)
except FetchError as e: # callers can catch the whole family
print(type(e).__name__, e, getattr(e, "retry_after", None))
requests follows exactly this design (RequestException and its
subclasses); Chapter 65 reads it.
7.5 Exception groups and add_note
Concurrent code can fail in several places at once. 3.11 added
ExceptionGroup and except* to handle that (asyncio's TaskGroup, Ch 29,
raises them), and add_note to attach context:
def validate(rows: list[dict]) -> None:
errors = []
for i, row in enumerate(rows):
if "id" not in row:
e = KeyError("id")
e.add_note(f"row {i}: {row}")
errors.append(e)
if errors:
raise ExceptionGroup("validation failed", errors)
try:
validate([{"id": 1}, {}, {"name": "x"}])
except* KeyError as eg:
for e in eg.exceptions:
print("missing", e, "|", e.__notes__[0])