Skip to content

Errors and Context Managers

Python signals failure by raising exceptions, and expects you to let them propagate unless you can genuinely handle them. The with statement is the companion feature: it guarantees cleanup runs no matter how a block exits.

An exception is an object that interrupts normal flow and unwinds the call stack until something catches it. Uncaught, it prints a traceback and exits with status 1.

def divide(a, b):
return a / b
divide(1, 0)
Traceback (most recent call last):
File "app.py", line 4, in <module>
divide(1, 0)
File "app.py", line 2, in divide
return a / b
~~^~~
ZeroDivisionError: division by zero

Read tracebacks bottom-up: the last line is the error, the line above it is where it happened, and the frames above that are how you got there. Python 3.11+ adds the ~~^~~ markers pinpointing the failing sub-expression on the line, which is a large debugging improvement on long expressions.

try:
value = int(user_input)
except ValueError:
print("not a number")
value = 0
else:
print("parsed cleanly")
finally:
print("always runs")
Clause Runs when
try Always — the protected code
except A matching exception was raised
else The try block completed with no exception
finally Always, on the way out — including via return, break, or an uncaught exception

else exists so the try block can stay as small as possible. Code that should not be protected goes in else:

try:
conn = connect()
except ConnectionError:
return None
else:
return conn.fetch() # a ConnectionError here is NOT swallowed

finally is for cleanup that must happen regardless:

f = open("data.txt", encoding="utf-8")
try:
process(f)
finally:
f.close()

(In practice you would use with for exactly this — see below.)

Catch the narrowest exception that you can actually do something about.

try:
config = json.loads(text)
except json.JSONDecodeError as exc:
logger.error("bad config: %s", exc)
config = DEFAULTS

Multiple handlers are tested top to bottom; the first match wins, so order from specific to general:

try:
...
except FileNotFoundError:
...
except OSError: # would also catch FileNotFoundError — must come second
...
except (ValueError, TypeError) as exc: # a tuple catches any of them
...
try:
risky()
except Exception:
logger.exception("risky() failed") # logs message + full traceback
raise # re-raise, preserving the original traceback

The exception object carries useful data:

try:
open("missing.txt")
except OSError as exc:
exc.errno # => 2
exc.strerror # => 'No such file or directory'
exc.filename # => 'missing.txt'
exc.args # the constructor arguments

Every exception inherits from BaseException. The structure is what makes broad-but-safe catches possible.

BaseException
├── SystemExit raised by sys.exit()
├── KeyboardInterrupt Ctrl-C
├── GeneratorExit
└── Exception <- catch THIS, not BaseException
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── AttributeError
├── EOFError
├── ImportError
│ └── ModuleNotFoundError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── NameError
│ └── UnboundLocalError
├── OSError
│ ├── FileNotFoundError
│ ├── FileExistsError
│ ├── PermissionError
│ ├── IsADirectoryError
│ ├── TimeoutError
│ └── ConnectionError
│ ├── ConnectionRefusedError
│ ├── ConnectionResetError
│ └── BrokenPipeError
├── RuntimeError
│ ├── RecursionError
│ └── NotImplementedError
├── StopIteration
├── SyntaxError
│ └── IndentationError
├── TypeError
├── ValueError
│ └── UnicodeDecodeError
└── Warning

The three exceptions above Exception are deliberately outside it: SystemExit, KeyboardInterrupt, and GeneratorExit are control flow, not errors, and except Exception: correctly lets them through.

Choosing what to raise:

Situation Raise
Wrong type of argument TypeError
Right type, unacceptable value ValueError
Missing dict key KeyError
Index out of range IndexError
Filesystem / network failure The relevant OSError subclass
Abstract method not overridden NotImplementedError
Nothing else fits and the state is wrong RuntimeError
raise ValueError("age must be non-negative")
raise ValueError # instantiated for you, but give a message

A bare raise inside an except block re-raises the current exception with its original traceback intact:

try:
parse()
except ValueError:
metrics.increment("parse_failures")
raise # not `raise exc` — the bare form preserves everything

Define your own so callers can catch your errors specifically. Give a library one base class so users can catch everything from it in one clause.

class AppError(Exception):
"""Base class for all errors from this application."""
class ConfigError(AppError):
"""Configuration is missing or invalid."""
class ValidationError(AppError):
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"{field}: {message}") # sets str(exc)
try:
load()
except ValidationError as exc:
print(exc.field)
except AppError:
print("something else in our app failed")

When you catch one exception and raise another, Python keeps the original attached so the traceback shows both.

Implicit chaining happens automatically when you raise inside an except block. The traceback reads “During handling of the above exception, another exception occurred”.

Explicit chaining with from states the causal relationship, and prints “The above exception was the direct cause of the following exception”:

try:
config = json.loads(raw)
except json.JSONDecodeError as exc:
raise ConfigError("config file is not valid JSON") from exc

The original is available as exc.__cause__ (explicit) or exc.__context__ (implicit).

from None suppresses the chain, when the inner exception is noise:

try:
value = mapping[key]
except KeyError:
raise ConfigError(f"missing setting: {key}") from None

When several things fail together — concurrent tasks, batched validation — ExceptionGroup carries them all, and except* handles them by type.

try:
raise ExceptionGroup("batch failed", [ValueError("a"), TypeError("b")])
except* ValueError as eg:
print("value errors:", eg.exceptions)
except* TypeError as eg:
print("type errors:", eg.exceptions)

Both handlers run. This is what asyncio.TaskGroup raises when multiple tasks fail.

assert condition, message raises AssertionError when the condition is falsy. It is a development-time sanity check, not error handling.

def average(xs):
assert xs, "average() requires a non-empty sequence"
return sum(xs) / len(xs)

Use assert for invariants you believe cannot be violated, and raise for conditions you expect might occur.

with binds a resource for the duration of a block and guarantees teardown, even on exception, return, or break.

with open("data.txt", encoding="utf-8") as f:
for line in f:
process(line)
# the file is closed here, guaranteed

Multiple resources in one statement, with parentheses for wrapping (Python 3.10+ formally allows the parenthesised form):

with (
open("in.txt", encoding="utf-8") as src,
open("out.txt", "w", encoding="utf-8") as dst,
):
dst.write(src.read())

Standard-library objects that are context managers include files, sockets, threading.Lock, database connections and cursors, tempfile.TemporaryDirectory, subprocess.Popen, decimal.localcontext, and unittest.mock.patch.

import threading, tempfile, pathlib
lock = threading.Lock()
with lock:
shared_state += 1 # released even if the body raises
with tempfile.TemporaryDirectory() as tmp:
(pathlib.Path(tmp) / "scratch.txt").write_text("hi", encoding="utf-8")
# the directory and its contents are deleted here

A context manager is any object with __enter__ and __exit__.

class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # this is what `as` binds
def __exit__(self, exc_type, exc_value, traceback):
import time
self.elapsed = time.perf_counter() - self.start
print(f"took {self.elapsed:.4f}s")
return False # falsy => propagate any exception
with Timer() as t:
heavy_work()

__exit__ receives the exception type, value, and traceback — all None if the block finished normally. Returning a truthy value suppresses the exception.

Selective suppression:

def __exit__(self, exc_type, exc_value, traceback):
if exc_type is ZeroDivisionError:
print("ignored a division by zero")
return True
return False

@contextlib.contextmanager turns a generator into a context manager: everything before yield is setup, the yielded value is bound by as, and everything after is teardown.

from contextlib import contextmanager
@contextmanager
def timer(label):
import time
start = time.perf_counter()
try:
yield label
finally:
print(f"{label}: {time.perf_counter() - start:.4f}s")
with timer("query"):
run_query()

A realistic example — a transaction that commits or rolls back:

@contextmanager
def transaction(conn):
cur = conn.cursor()
try:
yield cur
except Exception:
conn.rollback()
raise
else:
conn.commit()
finally:
cur.close()
with transaction(conn) as cur:
cur.execute("INSERT INTO users (name) VALUES (?)", ("Ada",))
from contextlib import suppress, closing, redirect_stdout, ExitStack
import io
# Ignore a specific exception — clearer than try/except/pass
with suppress(FileNotFoundError):
pathlib.Path("temp.txt").unlink()
# Call .close() on an object that has close() but no __exit__
with closing(legacy_resource()) as res:
res.use()
# Capture printed output
buf = io.StringIO()
with redirect_stdout(buf):
print("captured")
buf.getvalue() # => 'captured\n'
# A variable number of context managers
with ExitStack() as stack:
files = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
# all closed on exit, in reverse order

contextlib.AbstractContextManager is the ABC if you want to inherit; contextlib.asynccontextmanager is the async with equivalent, covered in typing and async.

  • Read tracebacks bottom-up; 3.11+ points at the exact failing sub-expression.
  • else runs when nothing was raised, finally always runs — never return from finally.
  • Catch the narrowest exception you can handle; never write a bare except:.
  • Exception is the safe broad catch; BaseException includes Ctrl-C and sys.exit().
  • A bare raise re-raises with the original traceback; raise X from exc records the cause.
  • Give each project one base exception class and derive the rest from it.
  • assert disappears under -O — never use it for validation.
  • with guarantees cleanup; write one via __enter__/__exit__ or @contextmanager with try/finally.