Emmanuel Akinfulubi

Errors, properly all 3 written

Raising and catching exceptions on purpose, writing your own, and the with statement that guarantees cleanup.

22 Exceptions: raise, try, except, else, finally

An exception is how Python reports that something cannot be done. Lesson 3 covered reading one; this covers raising them deliberately and catching them precisely. The discipline that matters is narrowness: catch the specific thing you can actually handle, and let everything else travel.

Raising on purpose

Validating input, and choosing the right exception type

ran at build time
def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"age must be an int, got {type(age).__name__}")
    if age < 0:
        raise ValueError(f"age cannot be negative, got {age}")
    return age

print("valid:", set_age(30))

seen = []
for bad in ["thirty", -5]:
    try:
        set_age(bad)
    except (TypeError, ValueError) as exc:
        seen.append(type(exc).__name__)
        print(f"{type(exc).__name__}: {exc}")

test("a valid value passes through", set_age(30), 30)
test("the wrong type raises TypeError and the wrong value ValueError",
     seen, ["TypeError", "ValueError"])

What it printed, when the build ran it

valid: 30
TypeError: age must be an int, got str
ValueError: age cannot be negative, got -5
ok   a valid value passes through
ok   the wrong type raises TypeError and the wrong value ValueError

Which exception to raise. TypeError when the argument is the wrong kind of thing; ValueError when the type is right but the value is not; KeyError and IndexError for lookups you implement yourself. Matching the built-in meanings means callers who already know Python already know what your function does.

Catching precisely

One except, several types

ran at build time
def parse_port(text):
    try:
        port = int(text)
    except ValueError:
        return None                      # this one we can handle
    return port

print("parse_port('8080') =", parse_port("8080"))
print("parse_port('abc')  =", parse_port("abc"))

# Several types in one except, as a tuple.
def read_first(data):
    try:
        return data[0]
    except (IndexError, KeyError, TypeError) as exc:
        return f"unavailable ({type(exc).__name__})"

print()
for value in [[1, 2], [], {"a": 1}, 42]:
    print(f"  {repr(value):>10} -> {read_first(value)}")

test("a handled error returns the fallback", parse_port("abc"), None)
test("and a good value passes through", parse_port("8080"), 8080)
test("a tuple of types catches any of them", read_first([]), "unavailable (IndexError)")
test("including a different one", read_first(42), "unavailable (TypeError)")

What it printed, when the build ran it

parse_port('8080') = 8080
parse_port('abc')  = None

      [1, 2] -> 1
          [] -> unavailable (IndexError)
    {'a': 1} -> unavailable (KeyError)
          42 -> unavailable (TypeError)
ok   a handled error returns the fallback
ok   and a good value passes through
ok   a tuple of types catches any of them
ok   including a different one

The bare except, and why it is a mistake

A bare except: catches everything — including KeyboardInterrupt when you press Ctrl-C and SystemExit when the program is trying to shut down. It turns a program you cannot interrupt into a bug you cannot find.

Bare except versus except Exception

ran at build time
# What a bare except really catches.
def swallow_everything():
    try:
        raise KeyboardInterrupt("user pressed Ctrl-C")
    except:                              # never write this
        return "swallowed the interrupt"

print(swallow_everything())

# 'except Exception' is the broad-but-defensible version: KeyboardInterrupt and
# SystemExit inherit from BaseException, NOT Exception, so they pass through.
def swallow_errors_only():
    try:
        raise KeyboardInterrupt("user pressed Ctrl-C")
    except Exception:
        return "swallowed"

try:
    swallow_errors_only()
except KeyboardInterrupt:
    print("except Exception let the interrupt through, as it should")

print()
print("KeyboardInterrupt is an Exception?    ", issubclass(KeyboardInterrupt, Exception))
print("KeyboardInterrupt is a BaseException?", issubclass(KeyboardInterrupt, BaseException))

test("a bare except catches even KeyboardInterrupt", swallow_everything(), "swallowed the interrupt")
test("KeyboardInterrupt does not inherit from Exception", issubclass(KeyboardInterrupt, Exception), False)
test("but it does from BaseException", issubclass(KeyboardInterrupt, BaseException), True)

What it printed, when the build ran it

swallowed the interrupt
except Exception let the interrupt through, as it should

KeyboardInterrupt is an Exception?     False
KeyboardInterrupt is a BaseException? True
ok   a bare except catches even KeyboardInterrupt
ok   KeyboardInterrupt does not inherit from Exception
ok   but it does from BaseException

The hierarchy is the point. BaseException is the root; Exception sits under it and is the parent of everything you would normally catch. KeyboardInterrupt, SystemExit and GeneratorExit deliberately sit outside Exception, precisely so that except Exception does not swallow them. Never write a bare except:; write except Exception: if you genuinely need breadth, and log what you caught.

else: the clause that keeps the try narrow

Code in the try block is code whose exceptions you are catching. Putting the follow-on work in there too means you might catch an error from the wrong line. The else clause runs only if nothing was raised, which lets the try hold only the risky line.

Narrowing the try with else

ran at build time
records = {"ada": "36"}

# Too much in the try: the ValueError could come from either line.
def loose(key):
    try:
        raw = records[key]
        return int(raw) * 2              # if THIS raises, we blame the lookup
    except KeyError:
        return "no such record"

# Narrow try, with the follow-on work in else.
def tight(key):
    try:
        raw = records[key]
    except KeyError:
        return "no such record"
    else:
        return int(raw) * 2              # only runs if the lookup succeeded

print("loose('ada'):", loose("ada"))
print("tight('ada'):", tight("ada"))
print("tight('bob'):", tight("bob"))

# With bad data, the difference shows: the error is no longer mistakenly
# attributable to the lookup.
records["bad"] = "not a number"
try:
    tight("bad")
except ValueError as exc:
    print()
    print("the ValueError now escapes clearly:", exc)

test("both work on good data", loose("ada"), tight("ada"))
test("and both handle a missing key", tight("bob"), "no such record")

What it printed, when the build ran it

loose('ada'): 72
tight('ada'): 72
tight('bob'): no such record

the ValueError now escapes clearly: invalid literal for int() with base 10: 'not a number'
ok   both work on good data
ok   and both handle a missing key

finally: runs whatever happens

finally runs on success, on handled errors, and on uncaught ones

ran at build time
log = []

def process(fail):
    log.append("open")
    try:
        if fail:
            raise RuntimeError("something broke")
        log.append("work")
        return "ok"
    except RuntimeError:
        log.append("handled")
        return "recovered"
    finally:
        log.append("close")          # runs on every path

success_result = process(False)
success_log = list(log)
print("success path:", success_result, success_log)

log.clear()
failure_result = process(True)
failure_log = list(log)
print("failure path:", failure_result, failure_log)

# It even runs when the exception is NOT handled.
log.clear()
def unhandled():
    try:
        raise KeyError("k")
    finally:
        log.append("cleanup ran anyway")

try:
    unhandled()
except KeyError:
    pass
print()
print("with an uncaught exception:", log)

test("finally ran on the success path", success_log, ["open", "work", "close"])
test("and on the handled-error path", failure_log, ["open", "handled", "close"])
test("and on the uncaught-exception path", log, ["cleanup ran anyway"])

What it printed, when the build ran it

success path: ok ['open', 'work', 'close']
failure path: recovered ['open', 'handled', 'close']

with an uncaught exception: ['cleanup ran anyway']
ok   finally ran on the success path
ok   and on the handled-error path
ok   and on the uncaught-exception path

There is one genuinely surprising thing about finally: a return inside it replaces whatever the function was already returning — and silently discards an in-flight exception.

Why finally should only ever clean up

ran at build time
def sneaky():
    try:
        return "from try"
    finally:
        return "from finally"        # overrides the return above

def worse():
    try:
        raise ValueError("this will vanish")
    finally:
        return "the exception was discarded"

print("return in finally wins:", sneaky())
print("and it swallows exceptions:", worse())
print()
print("Neither of these should ever be written. Use finally for cleanup only.")

test("a return in finally replaces the earlier one", sneaky(), "from finally")
test("and it silently discards a live exception", worse(), "the exception was discarded")

What it printed, when the build ran it

return in finally wins: from finally
and it swallows exceptions: the exception was discarded

Neither of these should ever be written. Use finally for cleanup only.
ok   a return in finally replaces the earlier one
ok   and it silently discards a live exception
example.py:5: SyntaxWarning: 'return' in a 'finally' block
  return "from finally"        # overrides the return above
example.py:11: SyntaxWarning: 'return' in a 'finally' block
  return "the exception was discarded"

Re-raising, and adding context

Catching an exception to log it and then letting it continue is common. A bare raise re-raises the current exception with its original traceback intact — which is what you want, because it preserves where the problem actually happened.

Re-raising, and add_note for extra context

ran at build time
log = []

def risky():
    return 1 / 0

def with_logging():
    try:
        return risky()
    except ZeroDivisionError:
        log.append("division failed, re-raising")
        raise                         # same exception, original traceback

try:
    with_logging()
except ZeroDivisionError as exc:
    print("caught again at the top:", type(exc).__name__, "-", exc)
    print("log:", log)

# Adding notes (3.11+) attaches context without changing the type.
def annotated():
    try:
        return 1 / 0
    except ZeroDivisionError as exc:
        exc.add_note("while computing the average of an empty list")
        raise

notes = []
try:
    annotated()
except ZeroDivisionError as exc:
    notes = list(exc.__notes__)
    print()
    print("the note travels with it:", notes)

test("a bare raise re-raises the same type", log, ["division failed, re-raising"])
test("add_note attaches the context to the exception",
     notes, ["while computing the average of an empty list"])

What it printed, when the build ran it

caught again at the top: ZeroDivisionError - division by zero
log: ['division failed, re-raising']

the note travels with it: ['while computing the average of an empty list']
ok   a bare raise re-raises the same type
ok   add_note attaches the context to the exception

Several exceptions at once: ExceptionGroup

Since 3.11 a single raise can carry several unrelated exceptions, and except* handles each kind separately. This exists for concurrent code, where five tasks can fail five different ways simultaneously — lesson 43 uses it properly.

ExceptionGroup and except*

ran at build time
def validate(record):
    problems = []
    if "name" not in record:
        problems.append(KeyError("name is required"))
    if record.get("age", 0) < 0:
        problems.append(ValueError("age cannot be negative"))
    if problems:
        raise ExceptionGroup("validation failed", problems)
    return "valid"

handled = []
try:
    validate({"age": -1})
except* KeyError as group:
    handled.append(("missing field", len(group.exceptions)))
except* ValueError as group:
    handled.append(("bad value", len(group.exceptions)))

print("both problems were reported, not just the first:")
for kind, count in handled:
    print(f"   {kind}: {count}")

print()
print("a valid record:", validate({"name": "Ada", "age": 30}))

test("both branches ran", len(handled), 2)
test("each saw its own exception type", sorted(k for k, _ in handled), ["bad value", "missing field"])
test("and a valid record raises nothing", validate({"name": "Ada"}), "valid")

What it printed, when the build ran it

both problems were reported, not just the first:
   missing field: 1
   bad value: 1

a valid record: valid
ok   both branches ran
ok   each saw its own exception type
ok   and a valid record raises nothing

Errors should not pass silently

The difference between handling an error and hiding it

ran at build time
# The anti-pattern: catching and doing nothing.
def silent(values):
    total = 0
    for v in values:
        try:
            total += int(v)
        except ValueError:
            pass                      # the bad data vanished without trace
    return total

# Better: keep the good data AND report what was skipped.
def reported(values):
    total, skipped = 0, []
    for v in values:
        try:
            total += int(v)
        except ValueError:
            skipped.append(v)
    return total, skipped

data = ["1", "2", "oops", "4"]
print("silent:  ", silent(data), " <- looks like a complete answer")
print("reported:", reported(data), "<- and you know what was dropped")

test("both total the usable values", silent(data), reported(data)[0])
test("only the second reports the problem", reported(data)[1], ["oops"])

What it printed, when the build ran it

silent:   7  <- looks like a complete answer
reported: (7, ['oops']) <- and you know what was dropped
ok   both total the usable values
ok   only the second reports the problem

What to take from this lesson

  • Raise TypeError for the wrong kind of thing and ValueError for the wrong value.
  • Catch specific types. Never except: — use except Exception: if you need breadth, and log what you caught.
  • Keep the try block down to the risky line; put the follow-on work in else.
  • finally always runs — use it for cleanup only, because a return in it discards both the earlier return and any live exception.
  • A bare raise preserves the original traceback; add_note attaches context.
  • except pass is how data disappears. Report what you skipped.

23 Your own exception types, and when to bother

Defining an exception type is three lines, so the interesting question is when to. The test is not "is this a special error" — it is "would a caller want to catch this failure and not others". If yes, it needs a name. If no, a built-in says it better.

The minimum

A custom exception is a class with a docstring

ran at build time
class ValidationError(Exception):
    """Raised when input does not meet the required shape."""

def check(age):
    if age < 0:
        raise ValidationError(f"age cannot be negative, got {age}")
    return age

print("valid:", check(30))

try:
    check(-1)
except ValidationError as exc:
    print("caught:", type(exc).__name__, "-", exc)

# It is an Exception like any other, with the usual behaviour for free.
print()
print("is an Exception:", issubclass(ValidationError, Exception))
print("its message:    ", ValidationError("boom").args)

test("the custom type is raised and caught", check(30), 30)
test("it inherits from Exception", issubclass(ValidationError, Exception), True)
test("and carries its message in args", ValidationError("boom").args, ("boom",))

What it printed, when the build ran it

valid: 30
caught: ValidationError - age cannot be negative, got -1

is an Exception: True
its message:     ('boom',)
ok   the custom type is raised and caught
ok   it inherits from Exception
ok   and carries its message in args

Inherit from Exception, not BaseException. Anything under BaseException but outside Exception is understood to mean "stop the program" — that is where KeyboardInterrupt and SystemExit live. An application error inheriting from BaseException would slip through every except Exception in the codebase, which is the opposite of useful.

A base class per library, which is the real pattern

Give your module or package one base exception, and derive everything from it. Callers then have a choice of precision: catch one specific failure, or catch anything your code raises, without knowing your internals.

One base class, three specific failures

ran at build time
class StorageError(Exception):
    """Base for every error this module raises."""

class NotFound(StorageError):
    """The requested key does not exist."""

class PermissionDenied(StorageError):
    """The caller may not access this key."""

class Corrupted(StorageError):
    """The stored value could not be decoded."""

DB = {"a": "1", "secret": "2", "bad": "not-a-number"}

def fetch(key, admin=False):
    if key not in DB:
        raise NotFound(f"no such key: {key!r}")
    if key == "secret" and not admin:
        raise PermissionDenied(f"{key!r} requires admin")
    try:
        return int(DB[key])
    except ValueError as exc:
        raise Corrupted(f"{key!r} is not a number") from exc

print("fetch('a') =", fetch("a"))

# A caller who cares about one case can catch just that one.
try:
    fetch("nope")
except NotFound as exc:
    print("specific:", exc)

# A caller who just wants to survive catches the base class.
print()
for key in ["nope", "secret", "bad"]:
    try:
        fetch(key)
    except StorageError as exc:
        print(f"  {type(exc).__name__:17} {exc}")

test("the happy path works", fetch("a"), 1)
test("every error shares the base class",
     all(issubclass(e, StorageError) for e in (NotFound, PermissionDenied, Corrupted)), True)
test("so one except clause catches all of them",
     isinstance(NotFound("x"), StorageError), True)
test("and admin access changes the outcome", fetch("secret", admin=True), 2)

What it printed, when the build ran it

fetch('a') = 1
specific: no such key: 'nope'

  NotFound          no such key: 'nope'
  PermissionDenied  'secret' requires admin
  Corrupted         'bad' is not a number
ok   the happy path works
ok   every error shares the base class
ok   so one except clause catches all of them
ok   and admin access changes the outcome

Carrying data, not just a message

An exception is an object, so it can hold whatever the handler needs. This is much better than forcing callers to parse your error message, which is what they will otherwise do.

An exception that carries what the handler needs

ran at build time
class RetryableError(Exception):
    def __init__(self, message, *, retry_after, attempts):
        super().__init__(message)          # keeps str(exc) working
        self.retry_after = retry_after
        self.attempts = attempts

def call_service(attempt):
    if attempt < 3:
        raise RetryableError(
            "service unavailable",
            retry_after=attempt * 2,
            attempts=attempt,
        )
    return "succeeded"

for attempt in range(1, 4):
    try:
        result = call_service(attempt)
    except RetryableError as exc:
        # The handler reads structured fields instead of parsing text.
        print(f"attempt {exc.attempts}: {exc} -- wait {exc.retry_after}s")
    else:
        print(f"attempt {attempt}: {result}")

test("str() still gives the message", str(RetryableError("x", retry_after=1, attempts=1)), "x")
test("and the structured data is reachable",
     RetryableError("x", retry_after=4, attempts=2).retry_after, 4)
test("the third attempt succeeds", call_service(3), "succeeded")

What it printed, when the build ran it

attempt 1: service unavailable -- wait 2s
attempt 2: service unavailable -- wait 4s
attempt 3: succeeded
ok   str() still gives the message
ok   and the structured data is reachable
ok   the third attempt succeeds

Call super().__init__(message). Skip it and str(exc) stops being the message: with positional arguments you get the whole argument tuple printed back at you, and with keyword-only arguments you get an empty string — so the exception appears as a blank line in every log and traceback it reaches. Both are shown below.

The consequence of forgetting super()

ran at build time
class Untidy(Exception):
    def __init__(self, message, code):
        self.message = message            # super() never called
        self.code = code

class Silent(Exception):
    def __init__(self, *, message, code):  # keyword-only, and no super()
        self.message = message
        self.code = code

class Correct(Exception):
    def __init__(self, message, code):
        super().__init__(message)
        self.code = code

for label, exc in [
    ("Untidy", Untidy("something failed", 500)),
    ("Silent", Silent(message="something failed", code=500)),
    ("Correct", Correct("something failed", 500)),
]:
    print(f"  {label:8} str() -> {str(exc)!r:25} args -> {exc.args!r}")

print()
print("Untidy prints the whole args tuple; Silent prints nothing at all;")
print("only Correct shows the message a reader wanted.")

test("positional args survive as a tuple even without super()", str(Untidy("x", 1)), "('x', 1)")
test("keyword-only args with no super() leave str() empty", str(Silent(message="x", code=1)), "")
test("calling super() gives the clean message", str(Correct("x", 1)), "x")

What it printed, when the build ran it

  Untidy   str() -> "('something failed', 500)" args -> ('something failed', 500)
  Silent   str() -> ''                        args -> ()
  Correct  str() -> 'something failed'        args -> ('something failed',)

Untidy prints the whole args tuple; Silent prints nothing at all;
only Correct shows the message a reader wanted.
ok   positional args survive as a tuple even without super()
ok   keyword-only args with no super() leave str() empty
ok   calling super() gives the clean message

Translating an exception with raise ... from

Lesson 3 showed the two connector sentences a chained traceback can carry. Here you choose which one your code produces. raise X from exc says "this was caused by that" and keeps the original for whoever debugs it.

from exc: the original cause is preserved

raises ConfigError
class ConfigError(Exception):
    pass

def load_port(raw):
    try:
        return int(raw["port"])
    except KeyError as exc:
        raise ConfigError("config is missing 'port'") from exc

load_port({})

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 6, in load_port
    return int(raw["port"])
               ~~~^^^^^^^^
KeyError: 'port'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "example.py", line 10, in <module>
    load_port({})
    ~~~~~~~~~^^^^
  File "example.py", line 8, in load_port
    raise ConfigError("config is missing 'port'") from exc
ConfigError: config is missing 'port'

Without from, raising inside an except block still shows both — but as "during handling of the above exception", which reads as an accident rather than a deliberate translation. And from None suppresses the original entirely, which is right when the internal cause would only confuse the caller.

__cause__ and __context__, and what sets each

ran at build time
class ConfigError(Exception):
    pass

def implicit(raw):
    try:
        return int(raw["port"])
    except KeyError:
        raise ConfigError("missing 'port'")          # no 'from'

def explicit(raw):
    try:
        return int(raw["port"])
    except KeyError as exc:
        raise ConfigError("missing 'port'") from exc  # deliberate

def suppressed(raw):
    try:
        return int(raw["port"])
    except KeyError:
        raise ConfigError("missing 'port'") from None  # hide the internals

observed = {}
for name, func in [("implicit", implicit), ("explicit", explicit), ("suppressed", suppressed)]:
    try:
        func({})
    except ConfigError as exc:
        cause = type(exc.__cause__).__name__ if exc.__cause__ else None
        context = type(exc.__context__).__name__ if exc.__context__ else None
        observed[name] = (cause, context)
        print(f"  {name:11} __cause__={cause!s:10} __context__={context}")

print()
print("__cause__ is set only by 'from'. __context__ is set automatically either way.")

test("a plain raise records context but no cause", observed["implicit"], (None, "KeyError"))
test("from exc sets the cause explicitly", observed["explicit"], ("KeyError", "KeyError"))
test("from None clears the cause", observed["suppressed"][0], None)

What it printed, when the build ran it

  implicit    __cause__=None       __context__=KeyError
  explicit    __cause__=KeyError   __context__=KeyError
  suppressed  __cause__=None       __context__=KeyError

__cause__ is set only by 'from'. __context__ is set automatically either way.
ok   a plain raise records context but no cause
ok   from exc sets the cause explicitly
ok   from None clears the cause

When not to define one

Use a built-in when a built-in fits. A function given a negative age should raise ValueError, not NegativeAgeError. A missing dict key should be KeyError. Callers already know how to handle those, and a type nobody will ever catch specifically is just noise. Define your own when a caller needs to distinguish your failure — typically at a library or subsystem boundary — and not before.

Three custom types replaced by two built-ins

ran at build time
# Over-engineered: three types nobody will catch separately.
class AgeTooSmallError(Exception): pass
class AgeTooLargeError(Exception): pass
class AgeNotIntegerError(Exception): pass

def over_engineered(age):
    if not isinstance(age, int):
        raise AgeNotIntegerError(age)
    if age < 0:
        raise AgeTooSmallError(age)
    if age > 150:
        raise AgeTooLargeError(age)
    return age

# The version anyone can use, with the same information in the message.
def sensible(age):
    if not isinstance(age, int):
        raise TypeError(f"age must be an int, got {type(age).__name__}")
    if not 0 <= age <= 150:
        raise ValueError(f"age must be 0-150, got {age}")
    return age

raised = []
for value in [-1, 200, "x"]:
    try:
        sensible(value)
    except (TypeError, ValueError) as exc:
        raised.append(type(exc).__name__)
        print(f"  {type(exc).__name__}: {exc}")

print()
print("Same diagnosis, built-in types, and no new vocabulary for callers to learn.")

test("the sensible version accepts a valid age", sensible(30), 30)
test("and reports the failures with built-in types",
     raised, ["ValueError", "ValueError", "TypeError"])

What it printed, when the build ran it

  ValueError: age must be 0-150, got -1
  ValueError: age must be 0-150, got 200
  TypeError: age must be an int, got str

Same diagnosis, built-in types, and no new vocabulary for callers to learn.
ok   the sensible version accepts a valid age
ok   and reports the failures with built-in types

What to take from this lesson

  • Define an exception when a caller needs to catch that failure specifically. Otherwise use a built-in.
  • Inherit from Exception, never BaseException.
  • Give a library one base exception and derive the rest from it, so callers can be precise or broad as they choose.
  • Put structured data on the exception rather than making callers parse the message — and call super().__init__(message).
  • raise X from exc for a deliberate translation; from None to hide an internal cause.

24 with: context managers and guaranteed cleanup

A file you opened must be closed. A lock you took must be released. A transaction you began must be committed or rolled back. In every case the cleanup has to happen even if the code in between raises, and with is how Python makes that guarantee structural rather than something you remember to do.

The problem with solves

The try/finally that with replaces

ran at build time
from pathlib import Path

Path("data.txt").write_text("line one\nline two\n")

# Without any protection: an exception between open and close leaks the handle.
f = open("data.txt")
first = f.readline()
f.close()
print("manual:", first.strip())

# The correct manual version is try/finally, which is verbose enough that
# people skip it -- which is exactly why 'with' exists.
f = open("data.txt")
try:
    first = f.readline()
finally:
    f.close()
print("try/finally:", first.strip())

# with does the same thing, and cannot be forgotten.
with open("data.txt") as f:
    first = f.readline()
print("with:", first.strip())

# The file is closed on the way out, whatever happened.
print()
print("closed after the with block:", f.closed)

test("all three read the same line", first.strip(), "line one")
test("the file is closed automatically", f.closed, True)

What it printed, when the build ran it

manual: line one
try/finally: line one
with: line one

closed after the with block: True
ok   all three read the same line
ok   the file is closed automatically

And the guarantee holds when the body raises, which is the entire point.

Cleanup runs, and the exception still travels

ran at build time
from pathlib import Path

Path("data.txt").write_text("content\n")

handle = None
try:
    with open("data.txt") as f:
        handle = f
        raise RuntimeError("something went wrong mid-read")
except RuntimeError as exc:
    print("the exception still propagated:", exc)

print("but the file was closed on the way out:", handle.closed)

test("the exception was not swallowed", handle is not None, True)
test("and cleanup happened anyway", handle.closed, True)

What it printed, when the build ran it

the exception still propagated: something went wrong mid-read
but the file was closed on the way out: True
ok   the exception was not swallowed
ok   and cleanup happened anyway

Two reasons this matters more than it looks. An unclosed file may not be flushed to disk, so data you "wrote" can be missing. And every operating system limits how many files a process may have open, so a leak in a loop eventually fails with OSError: too many open files — far from the code that caused it.

Several at once, and nesting

Several managers in one statement

ran at build time
from pathlib import Path

Path("in.txt").write_text("alpha\nbeta\n")

# One with statement, two managers. Both are closed, in reverse order.
with open("in.txt") as src, open("out.txt", "w") as dst:
    for line in src:
        dst.write(line.upper())

print("copied and upper-cased:", Path("out.txt").read_text().strip().split("\n"))

# Parenthesised form, which is the readable way when there are several (3.10+).
with (
    open("in.txt") as a,
    open("out.txt") as b,
):
    print("both open at once:", a.readline().strip(), "/", b.readline().strip())

test("the output file was written and closed", Path("out.txt").read_text(), "ALPHA\nBETA\n")

What it printed, when the build ran it

copied and upper-cased: ['ALPHA', 'BETA']
both open at once: alpha / ALPHA
ok   the output file was written and closed

Writing one as a class

Any object with __enter__ and __exit__ works with with. __enter__'s return value is what as binds; __exit__ is called on the way out and is told what went wrong, if anything.

__enter__ and __exit__, and what __exit__ is told

ran at build time
class Timer:
    """Measures how long its block took."""

    def __init__(self, label):
        self.label = label
        self.events = []

    def __enter__(self):
        self.events.append("enter")
        return self                      # this is what 'as' receives

    def __exit__(self, exc_type, exc_value, traceback):
        self.events.append("exit")
        self.failed = exc_type is not None
        # Returning None (or False) means "I did not handle it, let it travel".
        return None

with Timer("import") as t:
    t.events.append("body")

print("events:", t.events)
print("failed:", t.failed)

# __exit__ is told exactly what happened.
seen = {}

class Watcher:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        seen["type"] = exc_type.__name__ if exc_type else None
        seen["message"] = str(exc_value) if exc_value else None
        return None

try:
    with Watcher():
        raise ValueError("the body failed")
except ValueError:
    pass

print()
print("what __exit__ was told:", seen)

test("enter, body, exit ran in that order", t.events, ["enter", "body", "exit"])
test("a clean block reports no failure", t.failed, False)
test("__exit__ receives the exception type", seen["type"], "ValueError")
test("and its message", seen["message"], "the body failed")

What it printed, when the build ran it

events: ['enter', 'body', 'exit']
failed: False

what __exit__ was told: {'type': 'ValueError', 'message': 'the body failed'}
ok   enter, body, exit ran in that order
ok   a clean block reports no failure
ok   __exit__ receives the exception type
ok   and its message

The trap: returning True from __exit__

A truthy return from __exit__ means "I have handled this exception; do not propagate it". That is occasionally what you want and usually a bug — the failure disappears and the code after the block runs as though nothing happened.

A context manager that hides failures

ran at build time
class Swallows:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        return True                      # "handled" -- the exception vanishes

class Propagates:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        return None                      # the correct default

reached = []

with Swallows():
    raise ValueError("this will disappear")
reached.append("after Swallows")         # this line RUNS

try:
    with Propagates():
        raise ValueError("this one escapes")
except ValueError:
    reached.append("caught from Propagates")

print("what happened:", reached)
print()
print("The first block raised and the program carried on regardless.")

test("returning True swallowed the exception", "after Swallows" in reached, True)
test("returning None let it propagate", "caught from Propagates" in reached, True)

What it printed, when the build ran it

what happened: ['after Swallows', 'caught from Propagates']

The first block raised and the program carried on regardless.
ok   returning True swallowed the exception
ok   returning None let it propagate

Return None from __exit__ unless you mean it. Falling off the end of the method does this for you. The only legitimate reason to return True is a manager whose stated job is suppressing a specific exception — and the standard library already has contextlib.suppress for that, which is clearer than writing your own.

Writing one as a generator, which is usually shorter

@contextmanager turns a generator into a context manager. Everything before the yield is setup, the yielded value is what as binds, and everything after is cleanup — put in a finally so it runs even when the body raises.

@contextmanager: setup, yield, cleanup

ran at build time
from contextlib import contextmanager

log = []

@contextmanager
def tracked(name):
    log.append(f"open {name}")
    resource = name.upper()
    try:
        yield resource                   # the body runs here
    finally:
        log.append(f"close {name}")      # always runs

with tracked("db") as handle:
    log.append(f"body has {handle}")

success_log = list(log)
print("\n".join(success_log))

# And on the failing path.
log.clear()
try:
    with tracked("net"):
        raise RuntimeError("boom")
except RuntimeError:
    pass
failure_log = list(log)
print()
print("on failure:", failure_log)

test("setup, body and cleanup ran in order",
     success_log, ["open db", "body has DB", "close db"])
test("cleanup ran even though the body raised", failure_log, ["open net", "close net"])

What it printed, when the build ran it

open db
body has DB
close db

on failure: ['open net', 'close net']
ok   setup, body and cleanup ran in order
ok   cleanup ran even though the body raised

Without the try/finally, the cleanup is not guaranteed. If the body raises, the exception is thrown back in at the yield, and anything after it is skipped unless it is protected. This is the one mistake that makes a @contextmanager quietly useless.

The difference finally makes inside a @contextmanager

ran at build time
from contextlib import contextmanager

log = []

@contextmanager
def unprotected(name):
    log.append("open")
    yield name
    log.append("close")                  # skipped if the body raises

@contextmanager
def protected(name):
    log.append("open")
    try:
        yield name
    finally:
        log.append("close")

try:
    with unprotected("x"):
        raise RuntimeError("boom")
except RuntimeError:
    pass
unprotected_log = list(log)
print("unprotected:", unprotected_log, "<- never closed")

log.clear()
try:
    with protected("x"):
        raise RuntimeError("boom")
except RuntimeError:
    pass
protected_log = list(log)
print("protected:  ", protected_log)

test("without finally, cleanup is skipped on failure", unprotected_log, ["open"])
test("with finally, it always runs", protected_log, ["open", "close"])

What it printed, when the build ran it

unprotected: ['open'] <- never closed
protected:   ['open', 'close']
ok   without finally, cleanup is skipped on failure
ok   with finally, it always runs

The ones in the standard library worth knowing

suppress and redirect_stdout

ran at build time
from contextlib import suppress, redirect_stdout
from pathlib import Path
import io

# suppress: the honest version of try/except/pass, for when absence is fine.
Path("exists.txt").write_text("x")
with suppress(FileNotFoundError):
    Path("exists.txt").unlink()
with suppress(FileNotFoundError):
    Path("never-existed.txt").unlink()   # no error, by design
print("both unlink attempts completed; the file is gone:", not Path("exists.txt").exists())

# redirect_stdout: capture printed output, which is how you test a function
# that prints instead of returning.
buffer = io.StringIO()
with redirect_stdout(buffer):
    print("this went into the buffer")
captured = buffer.getvalue().strip()
print("captured:", repr(captured))

# A context manager can also be used without 'with', by calling the methods
# directly -- which is what a decorator-based one does under the hood.
print()
print("suppress is itself a class:", type(suppress(ValueError)).__name__)

test("suppress ignored the missing file", Path("never-existed.txt").exists(), False)
test("and the present one was really deleted", Path("exists.txt").exists(), False)
test("redirect_stdout captured the print", captured, "this went into the buffer")

What it printed, when the build ran it

both unlink attempts completed; the file is gone: True
captured: 'this went into the buffer'

suppress is itself a class: suppress
ok   suppress ignored the missing file
ok   and the present one was really deleted
ok   redirect_stdout captured the print

suppress deserves a caution of its own: it is except: pass with better syntax, and it has the same risk. Use it when absence genuinely does not matter, and never around a block big enough to hide a second, unrelated failure.

suppress has the same risk as except: pass

ran at build time
from contextlib import suppress

# Too broad: the KeyError from the second line is hidden too.
data = {"a": 1}
result = None
with suppress(KeyError):
    result = data["a"]
    result = data["missing"] + data["also_missing"]

print("result after a too-broad suppress:", result, "<- partial work, no complaint")

# Narrow: only the one lookup that is allowed to fail.
value = None
with suppress(KeyError):
    value = data["missing"]
print("narrow suppress leaves value as:", value)

test("the broad version silently kept partial work", result, 1)
test("the narrow one left the value unset", value, None)

What it printed, when the build ran it

result after a too-broad suppress: 1 <- partial work, no complaint
narrow suppress leaves value as: None
ok   the broad version silently kept partial work
ok   the narrow one left the value unset

What to take from this lesson

  • with is a reusable try/finally: it guarantees cleanup even when the body raises.
  • A context manager is any object with __enter__ and __exit__; __enter__'s return value is what as binds.
  • Return None from __exit__. Returning True swallows the exception.
  • @contextmanager is usually shorter — but the yield must be inside a try/finally or cleanup is skipped on failure.
  • suppress is tidier than except: pass and carries the same danger. Keep it narrow.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.