Exceptions and Context Managers

Article · 12:00

Python asks forgiveness rather than permission: attempt the operation and handle the failure if it comes, because checking first is often both slower and racier. Catching precisely, and guaranteeing cleanup with a context manager.

This lesson has 14 runnable examples. Press Try yourself! under any of them to open it in the editor.

Exceptions and Context Managers

Python's philosophy on failure is to try the thing and handle the failure, rather than to check first. Context managers are the same idea applied to cleanup: say what must be released, once, and let the language guarantee it.

Ask forgiveness, not permission

PY Example 1 15 lines
config = {"port": "5432"}

# Checking first - and there is a window between the check and the use.
if "port" in config and config["port"].isdigit():
    port = int(config["port"])
else:
    port = 3306

# Attempting, and handling the failure.
try:
    port = int(config["port"])
except (KeyError, ValueError):
    port = 3306

print(port)                             # 5432

Open this example in the editor and change it. Nothing you type is saved.

The second form is preferred in Python, and not only for brevity. The check-first version has a race in any program where the dict can change between the check and the use, it duplicates the condition, and it gets longer with every new way the operation can fail. The try version names the failures and handles them in one place.

This is not a licence to wrap everything in try. Use it where failure is genuinely expected and recoverable - parsing, I/O, network calls. Use a plain if where the condition is cheap, local and in your control.

Catching precisely

PY Example 2 8 lines
def parse(text):
    try:
        return int(text)
    except ValueError:
        return None


print(parse("12"), parse("x"))          # 12 None

Open this example in the editor and change it. Nothing you type is saved.

Several kinds at once, and the object itself:

PY Example 3 11 lines
import json

for payload in ['{"a": 1}', 'not json', '{"a": }']:
    try:
        data = json.loads(payload)
    except (json.JSONDecodeError, TypeError) as error:
        print(f"{type(error).__name__}: {error.args[0][:30]}")
    else:
        print("parsed", data)           # runs only if NO exception
    finally:
        pass                            # runs always

Open this example in the editor and change it. Nothing you type is saved.

The four clauses do different jobs:

  • try - keep it as small as you can. A long try body catches failures from lines you did not mean to guard.
  • except - name the exception. A bare except: catches KeyboardInterrupt and SystemExit too, so your program cannot be stopped.
  • else - runs when nothing was raised. It is where the success path goes, so the try can stay short.
  • finally - runs whatever happens, including on return and on an exception you are not catching. It is for cleanup.
PY Example 4 10 lines
def f():
    try:
        return "from try"
    finally:
        print("finally still runs")


print(f())
# finally still runs
# from try

Open this example in the editor and change it. Nothing you type is saved.

Catching too broadly is the most common mistake:

PY Example 5 12 lines
# No: hides typos, interrupts, and every bug you have not found yet.
# try:
#     risky()
# except Exception:
#     pass

# Yes: name what you expect, and let the rest propagate.
try:
    value = int("x")
except ValueError:
    value = 0
print(value)                            # 0

Open this example in the editor and change it. Nothing you type is saved.

An exception you did not expect is information. Swallowing it converts a loud failure into a silent wrong answer, which is strictly worse.

Raising, and chaining

PY Example 6 29 lines
class ConfigError(Exception):
    """Raised when configuration cannot be used."""

    def __init__(self, key, reason):
        super().__init__(f"{key}: {reason}")
        self.key = key
        self.reason = reason


def load_port(config):
    try:
        return int(config["port"])
    except KeyError as error:
        # `from error` keeps the original in __cause__, so the traceback
        # shows both. Without it you get a confusing "During handling of
        # the above exception, another exception occurred".
        raise ConfigError("port", "missing") from error
    except ValueError as error:
        raise ConfigError("port", f"not a number: {config['port']!r}") from error


for config in [{"port": "5432"}, {}, {"port": "abc"}]:
    try:
        print(load_port(config))
    except ConfigError as error:
        print(f"{error} (key={error.key}, caused by {type(error.__cause__).__name__})")
# 5432
# port: missing (key=port, caused by KeyError)
# port: not a number: 'abc' (key=port, caused by ValueError)

Open this example in the editor and change it. Nothing you type is saved.

raise ... from None suppresses the original entirely, which is right when the inner exception is an implementation detail the caller should not see.

Re-raising after logging is a bare raise, which preserves the original traceback:

PY Example 7 6 lines
try:
    int("x")
except ValueError:
    # log.exception("could not parse")
    raise                               # not `raise error` - that resets nothing
                                        # but reads as if it might

Open this example in the editor and change it. Nothing you type is saved.

Exception groups

When several things fail at once - parallel tasks, a batch of validations - one exception cannot carry them all.

PY Example 8 21 lines
def validate(record):
    problems = []
    if not record.get("name"):
        problems.append(ValueError("name is required"))
    if record.get("age", 0) < 0:
        problems.append(ValueError("age must not be negative"))
    if "@" not in record.get("email", ""):
        problems.append(ValueError("email looks wrong"))
    if problems:
        raise ExceptionGroup("invalid record", problems)
    return record


try:
    validate({"name": "", "age": -1, "email": "nope"})
except* ValueError as group:
    for error in group.exceptions:
        print("-", error)
# - name is required
# - age must not be negative
# - email looks wrong

Open this example in the editor and change it. Nothing you type is saved.

except* matches by type within the group and leaves the rest to propagate, so a handler can deal with the ValueErrors and let an OSError in the same group carry on upward.

Context managers

with guarantees cleanup. The classic case is a file, but the pattern applies to locks, transactions, timers, temporary settings and anything with a matched pair of operations.

PY Example 9 12 lines
# The long way.
handle = open("/etc/hostname", encoding="utf-8")
try:
    first = handle.readline()
finally:
    handle.close()

# The short way, with the same guarantee.
with open("/etc/hostname", encoding="utf-8") as handle:
    first = handle.readline()

print(first.strip() != "")

Open this example in the editor and change it. Nothing you type is saved.

Writing your own takes two methods:

PY Example 10 21 lines
import time


class Timer:
    def __init__(self, label):
        self.label = label
        self.ms = 0.0

    def __enter__(self):
        self.start = time.perf_counter()
        return self                     # whatever `as` binds

    def __exit__(self, exc_type, exc_value, traceback):
        self.ms = (time.perf_counter() - self.start) * 1000
        print(f"{self.label}: {self.ms:.1f}ms")
        return False                    # False re-raises; True SWALLOWS


with Timer("sum") as t:
    total = sum(range(200000))
print(total, t.ms > 0)

Open this example in the editor and change it. Nothing you type is saved.

The return value of __exit__ is the part people get wrong. Returning a truthy value suppresses the exception, which is almost never what you want - a context manager that silently swallows errors is a very hard bug to find. Return False, or nothing at all, unless suppression is the manager's whole purpose.

contextlib writes the class for you:

PY Example 11 28 lines
from contextlib import contextmanager


@contextmanager
def transaction(connection):
    connection.append("BEGIN")
    try:
        yield connection
    except Exception:
        connection.append("ROLLBACK")
        raise
    else:
        connection.append("COMMIT")


log = []
with transaction(log) as conn:
    conn.append("INSERT 1")
print(log)                              # ['BEGIN', 'INSERT 1', 'COMMIT']

log = []
try:
    with transaction(log) as conn:
        conn.append("INSERT 2")
        raise RuntimeError("boom")
except RuntimeError:
    pass
print(log)                              # ['BEGIN', 'INSERT 2', 'ROLLBACK']

Open this example in the editor and change it. Nothing you type is saved.

Everything before the yield is __enter__; everything after is __exit__. The try/except/else around the yield is how you distinguish success from failure.

Three more from contextlib worth knowing:

PY Example 12 19 lines
from contextlib import suppress, ExitStack, redirect_stdout
import io

# suppress: the ONE legitimate way to ignore an exception, because it
# names which one and covers exactly one block.
with suppress(FileNotFoundError):
    open("/no/such/file")
print("still here")

# ExitStack: a variable number of context managers.
with ExitStack() as stack:
    buffers = [stack.enter_context(io.StringIO()) for _ in range(3)]
    print(len(buffers))                 # 3 - all closed on exit

# redirect_stdout: capturing print output, mostly for tests.
captured = io.StringIO()
with redirect_stdout(captured):
    print("hidden")
print("captured:", captured.getvalue().strip())

Open this example in the editor and change it. Nothing you type is saved.

A worked example

PY Example 13 63 lines
from contextlib import contextmanager


class ValidationError(Exception):
    def __init__(self, field, reason):
        super().__init__(f"{field}: {reason}")
        self.field = field


@contextmanager
def collecting(label):
    """Gathers every failure in the block instead of stopping at the
    first - and raises them together at the end."""
    problems = []
    try:
        yield problems
    finally:
        if problems:
            raise ExceptionGroup(label, problems)


def check(record):
    with collecting(f"record {record.get('id', '?')}") as problems:
        for field in ("id", "email"):
            if not record.get(field):
                problems.append(ValidationError(field, "is required"))

        email = record.get("email", "")
        if email and "@" not in email:
            problems.append(ValidationError("email", f"no @ in {email!r}"))

        try:
            age = int(record.get("age", ""))
        except ValueError as error:
            problems.append(ValidationError("age", "not a whole number"))
        else:
            if age < 0:
                problems.append(ValidationError("age", "must not be negative"))


RECORDS = [
    {"id": 1, "email": "aisha@example.com", "age": "29"},
    {"id": 2, "email": "broken", "age": "x"},
    {"email": "sam@example.com", "age": "-1"},
]

for record in RECORDS:
    try:
        check(record)
    except* ValidationError as group:
        print(f"record {record.get('id', '?')}:")
        for error in group.exceptions:
            print("   -", error)
    else:
        print(f"record {record['id']}: ok")

# record 1: ok
# record 2:
#    - email: no @ in 'broken'
#    - age: not a whole number
# record ?:
#    - id: is required
#    - age: must not be negative

Open this example in the editor and change it. Nothing you type is saved.

Two design choices are worth naming. The validator collects rather than stopping at the first problem, because a form that reports one error at a time is a miserable form. And the finally in collecting means the group is raised even if the block itself raised something unrelated - the collected problems are not lost.

When it goes wrong

Symptom Cause
Ctrl-C would not stop the program A bare except: caught KeyboardInterrupt
A bug was silently swallowed except Exception: pass; name the exception
A traceback says "another exception occurred" Missing from error when re-raising
The traceback starts at the re-raise raise error instead of a bare raise
A file stayed open No with, and the finally was forgotten
An exception vanished inside with __exit__ returned a truthy value
finally overrode a return value A return in finally replaces everything
except* did not catch The exception was not in an ExceptionGroup

A check you can run

PY Example 14 7 lines
def f():
    try:
        return "A"
    finally:
        return "B"

print(f())

Open this example in the editor and change it. Nothing you type is saved.

It prints B. A return in a finally block discards the value the try was returning - and would equally discard an exception on its way out, silently. It is the one thing never to put in a finally, and seeing it swallow a return value is the fastest way to remember why.