Python Exceptions and Context Managers


  • Description: try/except/else/finally, raise and exception chaining, the exception hierarchy, custom exceptions, the with statement and writing custom context managers, contextlib, and assert
  • My Notion Note ID: K2A-D1-12
  • Created: 2023-01-10
  • Updated: 2026-05-11
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. try / except / else / finally

try:
    data = json.loads(text)
except json.JSONDecodeError as e:
    log.warning("bad json: %s", e)
    data = {}
except (OSError, ValueError) as e:        # multiple types in one except
    raise
else:
    log.info("parsed ok")                 # only when try succeeded
finally:
    cleanup()                             # always runs

Clause meanings:

  • except, match an exception

  • else, run if no exception was raised (keep success-path code here so unrelated raises don't get caught)

  • finally, always runs (success, exception, or return)

  • Order except clauses most-specific → most-general

  • Bare except: also catches KeyboardInterrupt and SystemExit, use except Exception: if you mean "any error"


2. raise and Re-Raising

raise ValueError("bad input")            # raise a fresh instance
raise ValueError                         # shorthand for ValueError()

try:
    ...
except ValueError:
    log.error(...)
    raise                                # re-raise (preserves traceback)
  • raise with no arg inside except, re-raises the active exception with its original traceback

3. Exception Chaining: raise ... from ...

try:
    parse(text)
except ValueError as e:
    raise ConfigError("invalid config") from e
  • from sets __cause__ → traceback shows "this exception is because of that one"
  • raise X from None suppresses chaining
  • Implicit chaining also happens: an exception raised inside except gets the active exception as __context__; from overrides it

4. The Exception Hierarchy

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception                  ← catch this, not BaseException
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   └── OverflowError
    ├── AttributeError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── OSError                # was IOError, EnvironmentError
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   ├── TimeoutError
    │   └── ...
    ├── RuntimeError
    │   ├── RecursionError
    │   └── NotImplementedError
    ├── StopIteration
    ├── TypeError
    ├── ValueError
    │   └── UnicodeError
    └── ...
  • Rule of thumb: catch Exception at top-level boundaries; catch specific subclasses inside library code

5. Custom Exceptions

class ConfigError(Exception):
    """Raised when configuration is malformed or missing."""

class MissingField(ConfigError):
    def __init__(self, field: str):
        super().__init__(f"missing required field: {field}")
        self.field = field
  • Add a docstring and put structured data in __init__ so callers can except on the class and read attributes
  • Avoid subclassing BaseException, bypasses the standard "catch all" filter

6. with and Context Managers

  • Guarantees cleanup, even on exception, Python's RAII
with open("data.txt") as f:
    process(f.read())
# f is closed here whether process() succeeded or raised

Multiple context managers (3.10+ allows parens for line breaks):

with (
    open("in.txt") as fin,
    open("out.txt", "w") as fout,
    lock,
):
    fout.write(transform(fin.read()))

Common managers in the stdlib:

Context manager Resource managed
open(...) File handle
threading.Lock() Mutex
tempfile.TemporaryDirectory() Temp dir (auto-removed)
contextlib.suppress(SomeError) Swallow the named exception
decimal.localcontext() Decimal context

7. Writing a Context Manager

Implement __enter__ and __exit__:

class Timer:
    def __enter__(self):
        self.t0 = time.perf_counter()
        return self                        # bound to `as` target
    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.t0
        # return True to swallow the exception; False/None to propagate

with Timer() as t:
    run_slow_thing()
print(t.elapsed)
  • __exit__ is called even when an exception is propagating, exc_type/exc/tb are non-None in that case

8. contextlib

Decorator form, usually shorter than a full class:

from contextlib import contextmanager

@contextmanager
def chdir(path):
    old = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(old)

with chdir("/tmp"):
    do_work()
  • yield separates setup from teardown
  • Wrap in try/finally if cleanup must run on exceptions

Other useful tools:

from contextlib import suppress, closing, ExitStack, nullcontext

with suppress(FileNotFoundError):
    os.remove("maybe.tmp")               # no error if missing

with closing(urllib.urlopen(url)) as page:
    ...                                  # calls page.close() on exit

with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]
    # all files closed on exit, in reverse order
  • ExitStack, for dynamic numbers of resources

9. assert

assert n >= 0, "n must be non-negative"
  • Removed under -O, never use assert for validation that must survive in production
  • Use for invariants and tests only

10. ExceptionGroup and except*

PEP 654 (3.11+), wraps multiple concurrent exceptions into one group; except* peels them off by type.

try:
    await asyncio.gather(t1, t2, t3)
except* ValueError as eg:
    log.warning("value errors: %s", eg.exceptions)
except* OSError as eg:
    log.error("io errors: %s", eg.exceptions)
  • Most application code can ignore this until working heavily with concurrent tasks