Skip to content
aviral gupta

// A1.4 · ~30 min · Advanced

Writing context managers

After this lesson you can write a class that works in a with statement, cleans up on every exit path and suppresses exactly the exceptions you choose.

Lesson 4 of 6 in A1 The data model

You will be able to

  • Write __enter__ and __exit__, and use the value __enter__ returns with as
  • Predict when __exit__ runs and which three arguments it receives
  • Suppress exceptions on purpose by returning True, and only for the right types
  1. Warm-up · Activity 1 of 7

    Warm-up from module I1: inside with open("data.txt") as f:, a line raises ValueError. What happens to the file?

  2. Predict · Activity 2 of 7

    Predict before you read on: what does this print?

    class Tag:
        def __init__(self, name):
            self.name = name
    
        def __enter__(self):
            print(f"<{self.name}>", end=" ")
            return self
    
        def __exit__(self, exc_type, exc, tb):
            print(f"</{self.name}>")
    
    
    with Tag("p") as t:
        print("hello from", t.name, end=" ")
  3. Practice · Activity 3 of 7

    Fill in the method name so that with Session() as s: binds s to "cursor-1".

    class Session:
        def ____(self):
            return "cursor-1"
    
        def __exit__(self, exc_type, exc, tb):
            pass
    def (self):
  4. Practice · Activity 4 of 7

    The block ends normally. What does __exit__ print?

    class Probe:
        def __enter__(self):
            return self
    
        def __exit__(self, exc_type, exc, tb):
            print(exc_type, exc, tb)
    
    
    with Probe():
        pass
  5. Practice · Activity 5 of 7

    Match each situation to what happens.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Quiet suppresses every exception. What does load() return?

    class Quiet:
        def __enter__(self):
            return self
    
        def __exit__(self, exc_type, exc, tb):
            return True
    
    
    def load():
        with Quiet():
            value = int("x")
            return value
        return -1
    
    
    print(load())
  7. Apply · Activity 7 of 7

    Mini-task. Write a class Indenter whose object can be used in nested with statements. Each with raises the level by one on entry and lowers it on exit, and a method print(text) prints the text after as many dashes as the current level.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A transaction on a dictionary

Transaction hands the block a draft copy through as. If the block finishes, __exit__ copies the draft back: committed. If the block raises, __exit__ throws the draft away and returns None, so the caller still sees the error and the stock is unchanged. Try raising in the first block, or make __exit__ return True.

main.py

from types import TracebackType


class Transaction:
    """Changes a copy of the data; commits it only if the block succeeds."""

    def __init__(self, data: dict[str, int]) -> None:
        self.data = data

    def __enter__(self) -> dict[str, int]:
        self.draft = dict(self.data)
        return self.draft

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        if exc_type is None:
            self.data.clear()
            self.data.update(self.draft)
            print("committed")
        else:
            # Returning None (false) lets the exception continue.
            print(f"rolled back after {exc_type.__name__}: {exc}")


stock = {"apples": 5, "pears": 2}

with Transaction(stock) as draft:
    draft["apples"] -= 3
print(stock)

try:
    with Transaction(stock) as draft:
        draft["apples"] -= 1
        draft["pears"] -= 5
        if draft["pears"] < 0:
            raise ValueError("not enough pears")
except ValueError:
    print("the caller still sees the ValueError")
print(stock)

Run it with

python main.py

Output

committed
{'apples': 2, 'pears': 2}
rolled back after ValueError: not enough pears
the caller still sees the ValueError
{'apples': 2, 'pears': 2}
  • as draft binds the copy returned by __enter__, not the Transaction.
  • The second block changed apples in the draft too, and that change was thrown away.
  • __exit__ reads exc_type.__name__ and exc to report what went wrong.
  • Because __exit__ returns None, the ValueError reaches the try around the with.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Tags that always close

Tag(name, out) writes lines into the list out. Add __enter__, which appends "<name>" and returns self, and __exit__, which appends "</name>". The closing line must also be written when the block raises, and the exception must still reach the caller.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    __exit__ takes four parameters: self, exc_type, exc and tb.

  2. Hint 2

    Python calls __exit__ after an exception too, so appending there is enough; no try is needed.

  3. Hint 3

    Let __exit__ return nothing: None is false, so the exception is not suppressed.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from types import TracebackType


class Tag:
    """Writes an opening line on entry and the closing line on exit."""

    def __init__(self, name: str, out: list[str]) -> None:
        self.name = name
        self.out = out

    def __enter__(self) -> "Tag":
        self.out.append(f"<{self.name}>")
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        self.out.append(f"</{self.name}>")


if __name__ == "__main__":
    lines: list[str] = []
    with Tag("ul", lines):
        with Tag("li", lines) as item:
            item.out.append("tea")
    print(lines)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from types import TracebackType


class Tag:
    """Writes an opening line on entry and the closing line on exit."""

    def __init__(self, name: str, out: list[str]) -> None:
        self.name = name
        self.out = out

    # Add __enter__: append "<name>" to self.out and return self.
    # Add __exit__: append "</name>", even when the block raised,
    # and do not suppress the exception.


if __name__ == "__main__":
    lines: list[str] = []
    with Tag("ul", lines):
        with Tag("li", lines) as item:
            item.out.append("tea")
    print(lines)

test_main.py

from main import Tag


def test_as_gives_the_tag():
    """with Tag(...) as t binds the Tag itself"""
    out = []
    tag = Tag("p", out)
    with tag as t:
        assert t is tag, f"the as target is {t!r}, expected the Tag: return self from __enter__"


def test_nested_lines():
    """Nested tags open and close in the right order"""
    out = []
    with Tag("ul", out):
        with Tag("li", out):
            out.append("tea")
    assert out == ["<ul>", "<li>", "tea", "</li>", "</ul>"], f"the lines are {out!r}"


def test_closes_on_error():
    """The closing line is written even when the block raises"""
    out = []
    try:
        with Tag("p", out):
            raise ValueError("boom")
    except ValueError:
        pass
    assert out == ["<p>", "</p>"], f"after an error the lines are {out!r}, expected ['<p>', '</p>']"


def test_does_not_suppress():
    """An exception in the block still reaches the caller"""
    try:
        with Tag("p", []):
            raise ValueError("boom")
    except ValueError:
        return
    raise AssertionError("the ValueError was swallowed: __exit__ must not return True")

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A selective Ignore

Finish __exit__ in Ignore. It suppresses an exception only if it is an instance of one of self.types (subclasses count), and then stores the exception object in self.caught. Every other exception must reach the caller, and a clean block leaves caught as None.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    On a clean exit exc_type is None, and issubclass(None, ...) would fail, so check that first.

  2. Hint 2

    issubclass accepts a tuple of classes: issubclass(exc_type, self.types).

  3. Hint 3

    if exc_type is not None and issubclass(exc_type, self.types): self.caught = exc; return True. Otherwise return False.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from types import TracebackType


class Ignore:
    """Suppresses the given exception types and remembers what it caught."""

    def __init__(self, *types: type[BaseException]) -> None:
        self.types = types
        self.caught: BaseException | None = None

    def __enter__(self) -> "Ignore":
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> bool:
        if exc_type is not None and issubclass(exc_type, self.types):
            self.caught = exc
            return True
        return False


if __name__ == "__main__":
    prices: dict[str, int] = {}
    with Ignore(KeyError) as guard:
        prices["tea"]
    print("caught:", repr(guard.caught))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from types import TracebackType


class Ignore:
    """Suppresses the given exception types and remembers what it caught."""

    def __init__(self, *types: type[BaseException]) -> None:
        self.types = types
        self.caught: BaseException | None = None

    def __enter__(self) -> "Ignore":
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> bool:
        # Suppress (return True) only exceptions of self.types or their
        # subclasses, and store the exception in self.caught.
        return False


if __name__ == "__main__":
    prices: dict[str, int] = {}
    with Ignore(KeyError) as guard:
        prices["tea"]
    print("caught:", repr(guard.caught))

test_main.py

from main import Ignore


def test_suppresses_listed_type():
    """A listed exception is suppressed and stored"""
    with Ignore(ValueError) as guard:
        raise ValueError("bad number")
    assert isinstance(guard.caught, ValueError), f"guard.caught is {guard.caught!r}, expected the ValueError"


def test_subclasses_count():
    """Ignore(LookupError) also suppresses KeyError"""
    with Ignore(LookupError) as guard:
        {}["missing"]
    assert isinstance(guard.caught, KeyError), f"guard.caught is {guard.caught!r}, expected a KeyError"


def test_other_types_propagate():
    """An exception that is not listed reaches the caller"""
    try:
        with Ignore(ValueError):
            raise KeyError("id")
    except KeyError:
        return
    raise AssertionError("Ignore(ValueError) swallowed a KeyError")


def test_clean_block():
    """Without an exception, caught stays None"""
    with Ignore(ValueError) as guard:
        pass
    assert guard.caught is None, f"guard.caught is {guard.caught!r} after a clean block"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

__enter__ without return

class LogFile:
    def __init__(self):
        self.lines = []

    def __enter__(self):
        self.lines.append("start")

    def __exit__(self, exc_type, exc, tb):
        self.lines.append("end")


with LogFile() as log:
    log.lines.append("working")

What Python prints

AttributeError: 'NoneType' object has no attribute 'lines'

Why, and the fix

The name after as gets whatever __enter__ returns, and a function without return returns None. End __enter__ with return self, or return the resource the block should use.

__exit__ with the wrong parameters

class Tag:
    def __enter__(self):
        return self

    def __exit__(self):
        print("closing")


with Tag():
    print("inside")

What Python prints

TypeError: Tag.__exit__() takes 1 positional argument but 4 were given

Why, and the fix

Python always passes three arguments to __exit__: exc_type, exc and tb, which are None after a clean block. Write def __exit__(self, exc_type, exc, tb):. The error comes after the block has already run.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

The two methods

with manager as target: calls manager.__enter__() and binds its return value to target. That is often self, but it can be anything: open() returns the file, a transaction might return a draft copy. Forget the return, and target is None. When the block ends, Python calls __exit__(exc_type, exc, tb). The object class provides neither method, so both must be written; a class without __exit__ is rejected with TypeError.

__exit__ always runs

Once __enter__ has returned without an error, __exit__ is guaranteed to run: after the last line, after return, break or continue, and after an exception. On a clean exit all three arguments are None; after an exception they are its type, the exception object and its traceback. So test exc_type is None to tell the two apart. If __enter__ itself raises, the block never starts and __exit__ is not called.

Suppressing on purpose

The return value of __exit__ only matters after an exception. A true value suppresses it: execution continues after the with statement, and the rest of the block is skipped. None or False lets the exception continue; do not re-raise it yourself. Suppress narrowly, with issubclass(exc_type, allowed), and beware of returning a truthy value by accident, such as a non-empty list.

Sources

Last reviewed September 29, 2026