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
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
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:
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 longtrybody catches failures from lines you did not mean to guard.except- name the exception. A bareexcept:catchesKeyboardInterruptandSystemExittoo, so your program cannot be stopped.else- runs when nothing was raised. It is where the success path goes, so thetrycan stay short.finally- runs whatever happens, including onreturnand on an exception you are not catching. It is for cleanup.
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:
# 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
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:
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.
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.
# 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:
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:
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:
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
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
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.