Context Managers in Python: with, __enter__, __exit__ and contextlib

Learn how Python's with statement guarantees cleanup, then build and trace class-based and generator-based context managers with runnable examples.

KnowledgeGate Team

Exam prep & CS education

Updated 14 Oct 20266 min read

Files, locks and temporary settings all need cleanup, but a manual close() or restore call is easy to skip when an exception interrupts normal control flow. Python's with statement delegates setup and teardown to a context manager, so the cleanup path runs after both normal completion and failure. The Python Programming course places this protocol in a broader language sequence.

The problem a context manager solves

A context manager is an object that sets up and tears down a bounded context. That context might hold an open file, a lock, a database transaction or a temporary configuration value. Its exit logic runs when control leaves the with block, whether the body finishes normally or raises an exception.

The official Python data model gives the protocol. For with manager as value:, Python evaluates manager, calls __enter__(), binds its return value to value, runs the body, then calls __exit__(exc_type, exc_value, traceback). All three exception arguments are None after a normal exit.

Cleanup does not imply success. A manager can close a resource or restore state after failure, but it does not automatically retry work, undo mutations or suppress the exception.

Read a file and verify that it closes

Run this complete example in a directory where Python can create scores.txt:

python
from pathlib import Path

path = Path("events.txt")
path.write_text("cache,hit\nqueue,miss\n", encoding="utf-8")

with path.open("r", encoding="utf-8") as handle:
    first = handle.readline().rstrip("\n")
    rest = handle.read().rstrip("\n")
    print(first)
    print(rest)
    print(handle.closed)

print(handle.closed)

The exact output is:

Code
cache,hit
queue,miss
False
True

The sample has a 10-character first line and an 11-character second line, counting each newline, so it contains 21 ASCII characters and occupies 21 bytes in UTF-8. Inside the block, the handle works and handle.closed is False. After exit, the same object's flag is True because the file manager closed it.

With manual path.open() followed later by close(), an intervening exception can bypass cleanup unless you write a correct try/finally. Use with when a file's lifetime matches one block.

Build a class-based manager and trace every state change

A class-based manager exposes the protocol and its state:

python
class ScoreBatch:
    def __init__(self, scores):
        self.scores = scores
        self.is_open = False
        self.events = []

    def __enter__(self):
        self.is_open = True
        self.events.append("enter")
        return self

    def add(self, score):
        if not self.is_open:
            raise RuntimeError("batch is closed")
        self.scores.append(score)

    def __exit__(self, exc_type, exc_value, traceback):
        self.is_open = False
        name = "none" if exc_type is None else exc_type.__name__
        self.events.append(f"exit:{name}")
        return False


manager = ScoreBatch([72, 81])
with manager as batch:
    batch.add(90)
    print(batch.scores, batch.is_open)

print(manager.scores, manager.is_open, manager.events)

It prints exactly:

Code
[72, 81, 90] True
[72, 81, 90] False ['enter', 'exit:none']

__enter__() changes is_open, records "enter" and returns self. Therefore, batch is manager is True. If it returned nothing, batch would be None. The body appends 90; __exit__() changes only the open state and event log.

Returning False is deliberate. It means an exception from the body is not swallowed. Do not call these dunder methods manually. The with statement orders the calls and supplies the exception information.

ScoreBatch running normally: __enter__ opens the batch, a score is added in the with block, then __exit__ closes it with no error.

Exception flow: cleanup, propagation and suppression

Reuse the same class and raise an error inside its block:

python
manager = ScoreBatch([72])

try:
    with manager as batch:
        batch.add(95)
        raise ValueError("score 120 is outside 0..100")
except ValueError as error:
    print(error)

print(manager.scores)
print(manager.is_open)
print(manager.events)

The output is:

Code
score 120 is outside 0..100
[72, 95]
False
['enter', 'exit:ValueError']

Cleanup runs before the outer except, but the appended 95 remains. This manager cleans up; it does not roll back data. A false return from __exit__() lets the ValueError propagate. A true return suppresses it, so an unconditional truthy return can hide bugs. Even selective code such as return exc_type is ValueError suppresses every ValueError raised by the body, not just one expected validation error.

How __exit__ handles an exception in the with block: return False lets the error propagate to the outer except; return True suppresses it.

Write the same pattern with @contextmanager

The official Python contextlib documentation defines the generator form. Code before yield performs setup, the yielded value becomes the as target, and a finally block performs teardown. The decorated generator must yield exactly once.

python
from contextlib import contextmanager

@contextmanager
def temporary_mode(settings, new_mode):
    old_mode = settings["mode"]
    settings["mode"] = new_mode
    try:
        yield settings
    finally:
        settings["mode"] = old_mode


settings = {"mode": "safe", "retries": 2}
with temporary_mode(settings, "fast") as current:
    print(current)

print(settings)

The exact outputs are {'mode': 'fast', 'retries': 2} inside and {'mode': 'safe', 'retries': 2} after the block. Replace the inner print with raise RuntimeError("stop") and the finally clause still restores mode to "safe"; the error propagates because the generator does not catch it.

Use a class when a manager needs reusable state or methods. Use @contextmanager when a short setup, yield and finally sequence reads more clearly. The earlier Python Decorators and Generators: yield, Closures and the Trace-the-Output Interview Questions develops closures, @ syntax and yield as general language features. In @contextmanager, those mechanisms serve one narrower contract: setup before a single yield and teardown after it.

Common mistakes and the right contextlib tool

Mistake

Result

Correction

Forget return self in __enter__()

The as target is None

Return the intended managed value

Return a truthy value from __exit__()

Failures can disappear

Return False unless suppression is deliberate

Put cleanup after yield without finally

An error can skip restoration

Put teardown in finally

Call __enter__() directly

You lose with coordination

Enter through a with statement

Use the managed value after exit

The resource may be closed

Keep its use inside the block

Assume cleanup means rollback

Mutations such as appended 95 remain

Implement rollback separately if required

Use closing(obj) for an object with close() but no context protocol, suppress(ExpectedError) for one deliberately ignored exception, nullcontext(value) for an optional no-op context, and ExitStack for a dynamic number of contexts. For asynchronous resources, use async with, __aenter__() and __aexit__(). The suppress context manager handles a deliberately ignored exception:

python
from contextlib import suppress

states = {"cache": "ready"}
with suppress(KeyError):
    del states["worker"]
print(states)
Code
{'cache': 'ready'}

Deleting the missing key "worker" raises KeyError, suppress(KeyError) swallows just that one exception, and the dict is unchanged.

How interviews and assessments test context managers

Stable questions ask you to expand with into protocol steps, predict whether __exit__() receives None or ValueError, decide whether a return value propagates an exception, explain a None target, or rewrite try/finally with @contextmanager.

For the error trace above, state manager.scores, manager.is_open and manager.events. The checkable answer is [72, 95], False and ['enter', 'exit:ValueError']. When you self-check, verify the enter, bind, run, exit order, whether the return value makes the exception propagate, and whether cleanup leaves earlier mutations such as the appended 95 intact.

Practice tasks and the recall chain

  1. Make ScoreBatch.__enter__() return self.scores. Starting with [60, 75], as scores must reference that list, appending 80 must produce [60, 75, 80], and manager.is_open must be False after exit.

  2. Implement temporary_retries(settings, 5) for {"mode": "safe", "retries": 2}. Retries must be 5 inside and return to 2 after RuntimeError("stop"), which must still propagate.

  3. Predict return exc_type is KeyError: it suppresses KeyError("missing"), but not ValueError("bad").

Run each task and check its output against the stated result. Use the Python course linked above for structured language study, then continue with DSA Using Python for later problem-solving practice. For interview topics beyond Python itself, browse the Coding and Skill Development category for related guides.

The recall chain: enter, bind, run, exit, then propagate unless suppression is deliberate.