Exceptions and context managers
Design explicit failure boundaries, preserve exception context, aggregate concurrent errors, and guarantee resource cleanup.
Exceptions separate the successful path from failure handling. A useful exception identifies what operation failed, carries relevant structured context, and is caught at a layer that can actually recover or translate it.
Raise specific exceptions
class ConfigurationError(Exception):
def __init__(self, message: str, *, key: str | None = None) -> None:
super().__init__(message)
self.key = key
def require_setting(settings: dict[str, str], key: str) -> str:
try:
return settings[key]
except KeyError as error:
raise ConfigurationError(
f"missing required configuration: {key}",
key=key,
) from errorThe domain exception tells callers what failed. raise ... from error explicitly
chains the low-level cause, preserving useful debugging context. Use
raise ... from None only when the lower-level exception is an irrelevant
implementation detail and suppressing its display improves the API.
Do not put secrets, tokens, full queries, or sensitive payloads in exception messages; they often reach logs and error trackers.
Catch only what you can handle
def load_port(raw: str | None) -> int:
if raw is None:
return 8000
try:
port = int(raw)
except ValueError as error:
raise ConfigurationError("PORT must be an integer") from error
if not 1 <= port <= 65_535:
raise ConfigurationError("PORT must be between 1 and 65535")
return portCatch close to the operation when translating a known failure. Catch near an application boundary when logging, returning an HTTP response, or deciding whether a process can continue.
Avoid broad except Exception inside reusable code. It can hide programmer
errors such as TypeError and AttributeError. A top-level worker loop may catch
Exception to isolate jobs, but it should log the traceback and make a deliberate
retry or failure decision. Bare except: also catches KeyboardInterrupt and
SystemExit, which generally must propagate.
else and finally
try:
payload = read_payload()
except OSError as error:
report_io_failure(error)
else:
process(payload)
finally:
release_temporary_state()The else block runs only when the try suite succeeds. Keeping process
outside the try prevents its unrelated exceptions from being caught as read
failures. finally runs whether the suite returns, raises, or succeeds.
Never return from finally: it can suppress an active exception or replace a
previous return value.
Exception groups
Concurrent operations may fail independently. Python 3.11 introduced
ExceptionGroup and except* so callers can handle matching subgroups:
def validate_batch(values: list[str]) -> None:
errors: list[Exception] = []
for index, value in enumerate(values):
if not value:
errors.append(ValueError(f"item {index} is empty"))
if errors:
raise ExceptionGroup("batch validation failed", errors)
try:
validate_batch(["ok", "", ""])
except* ValueError as group:
for error in group.exceptions:
print(error)Use groups when failures are truly independent and preserving all of them helps the caller. A normal exception remains clearer for a single causal path.
The context manager protocol
with manager as value calls manager.__enter__(), executes the body, then calls
manager.__exit__(type, value, traceback). Cleanup runs even when the body
raises.
from types import TracebackType
class Transaction:
def __enter__(self) -> "Transaction":
self.begin()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool:
if exc_type is None:
self.commit()
else:
self.rollback()
return False
def begin(self) -> None:
print("begin")
def commit(self) -> None:
print("commit")
def rollback(self) -> None:
print("rollback")Returning false tells Python to propagate any body exception. Returning true suppresses it and should be reserved for managers explicitly designed to handle that failure. Accidental truthy returns are dangerous.
Generator-based context managers
For simple acquire/yield/release behavior, contextlib.contextmanager removes
protocol boilerplate:
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
@contextmanager
def temporary_workspace() -> Iterator[Path]:
with TemporaryDirectory() as directory:
yield Path(directory)Code before yield enters the context; code after it exits. Wrap yield in
try/finally when cleanup is not already delegated to another manager. The
decorated generator must yield exactly once.
ExitStack composes a dynamic number of context managers and cleanup callbacks.
AsyncExitStack does the same for asynchronous resources. Prefer these tools to
manually nested conditional cleanup.
Asynchronous context managers
An async context manager implements __aenter__ and __aexit__ and is consumed
with async with. Use it when acquisition or release itself awaits I/O, such as
opening a client session or transaction. Do not perform blocking cleanup in an
async method; it stalls the event loop.
Tradeoffs and pitfalls
- Exceptions avoid error-code plumbing but make nonlocal control flow possible.
- Custom exception hierarchies help callers catch at the right level, but dozens of single-use types add noise.
- Using exceptions for expected high-frequency branching may be less readable than an explicit result.
- Context managers make cleanup deterministic, unlike
__del__. - Catching, logging, and re-raising at every layer creates duplicate logs.
- Retrying requires a defined transient failure set, backoff, and an idempotency strategy.
Practice
- Translate a
KeyErrorinto a domain exception while preserving the cause. - Refactor a
tryblock so its success-only work moves intoelse. - Implement a context manager that restores a changed setting after failure.
- Use
ExitStackto open a runtime-sized list of files and ensure all close.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK
Exceptions and context managers
Type hints and annotations
Model precise contracts with modern unions, generics, protocols, narrowing, and postponed annotation evaluation.
Comprehensions, iterators, and generators
Build correct lazy pipelines by understanding iterable protocols, one-shot iterators, generator control flow, and readable comprehensions.