Context Managers
The with statement is Python's answer to 'always clean this up, no matter what' — context managers guarantee deterministic setup and teardown around a block of code.
Many resources — open files, locks, network connections, database transactions — need to be released deterministically, not whenever the garbage collector eventually gets around to it. The with statement, backed by the context management protocol (__enter__/__exit__), is Python's built-in mechanism for guaranteeing that setup and cleanup happen in pairs, even when an exception occurs in between.
What Is a Context Manager
A context manager is any object implementing two methods: __enter__(self), called when a with block begins, and __exit__(self, exc_type, exc_value, traceback), called when the block ends — whether it ended normally or because of an exception. Together this is the 'context management protocol'. The built-in open() returns a file object that is itself a context manager, which is why with open(...) as f: is the idiomatic way to work with files.
open() returns a file object; entering the with block calls its __enter__ (which returns the file object itself), and leaving the block — for any reason — calls __exit__, which closes the file.
with open("data.txt") as f:
contents = f.read()
# f.close() has already been called here, guaranteedWhy with Matters: Deterministic Cleanup
Without with, resource cleanup depends on either remembering to call .close() explicitly (easy to forget, especially on early returns or exceptions) or relying on garbage collection to eventually close it — which is not deterministic, and in CPython specifically depends on reference counting behavior that other implementations (PyPy, etc.) are not required to match. with guarantees the cleanup runs exactly when the block exits, regardless of how it exits.
In the fragile version, an exception in do_something(f) skips f.close() entirely; the with version calls __exit__ (which closes the file) no matter what happens inside the block.
# Fragile: if do_something raises, the file is never explicitly closed here
f = open("data.txt")
do_something(f)
f.close()
# Robust: close() is guaranteed regardless of what happens inside
with open("data.txt") as f:
do_something(f)The with Statement, Step by Step
with expr as name: evaluates expr to get the context manager, calls its __enter__(), binds the return value to name (the as name part is optional), runs the block, then calls __exit__() on the same object when the block finishes — whether that's via falling off the end, return, break, continue, or an exception.
__enter__'s return value (self here) is what 'as r' binds to; __exit__ runs even though no exception occurred, right after the block's last statement.
class Resource:
def __enter__(self):
print("acquiring")
return self
def __exit__(self, exc_type, exc_value, tb):
print("releasing")
return False # do not suppress exceptions
with Resource() as r:
print("using", r)
# Output:
# acquiring
# using <Resource object>
# releasing__enter__ and __exit__
__enter__(self) takes only self and returns whatever should be bound by as; it's common for it to return self, but it can return something else entirely (or nothing meaningful, if as isn't used). __exit__(self, exc_type, exc_value, traceback) always receives exception info: all three are None if the block completed without error, or the exception's type, instance, and traceback if it raised.
__enter__ records the start time and returns self; __exit__ always runs (whether or not an exception occurred) and reports elapsed time either way.
class Timer:
def __enter__(self):
import time
self._start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, tb):
import time
elapsed = time.perf_counter() - self._start
print(f"elapsed: {elapsed:.4f}s")
return False
with Timer():
sum(range(10_000_000))Suppressing Exceptions from __exit__
If __exit__ returns a truthy value, the exception (if any) is suppressed — the with block completes as if nothing happened, and code after it continues normally. Returning False (or None, which is falsy) lets the exception propagate as usual. Suppressing exceptions is a deliberate, narrow tool — use it only when swallowing that specific exception is genuinely the intended behavior.
Checking exc_type explicitly means only ZeroDivisionError is swallowed; any other exception type would still propagate normally since the comparison would be False.
The standard library's contextlib.suppress (covered in the contextlib topic) is the idiomatic, ready-made way to do exactly this without writing a custom class.
class IgnoreZeroDivision:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
return exc_type is ZeroDivisionError # True suppresses only this type
with IgnoreZeroDivision():
result = 1 / 0
print("still running") # reached, because ZeroDivisionError was suppressedMultiple Context Managers in One with
A single with statement can manage several context managers at once, comma-separated; they are entered left to right and exited right to left (like nested with blocks), and this is equivalent to (but more concise than) writing them nested.
Both files are guaranteed to be closed when the block exits, even if writing raises partway through — dst is exited (closed) before src, mirroring the nested version's order.
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())
# Equivalent to:
with open("in.txt") as src:
with open("out.txt", "w") as dst:
dst.write(src.read())Common Built-in Context Managers
Beyond open(), several standard library objects are context managers out of the box: threading.Lock (acquire/release), decimal.localcontext(), tempfile.TemporaryDirectory(), database connection/cursor objects in DB-API-compliant libraries, and more. Recognizing 'this needs guaranteed cleanup' is usually a sign a context manager already exists or should be written.
threading.Lock implements __enter__ (acquire) and __exit__ (release), so 'with lock:' is both shorter and safer than manual acquire()/release() with try/finally.
import threading
lock = threading.Lock()
with lock:
# critical section; lock is released automatically on exit
shared_counter.increment()Mental Model
Think of with expr as name: as sugar for a try/finally with an explicit setup step: it always runs 'enter', always runs 'exit' no matter how the block ends, and gives __exit__ a chance to inspect (and optionally suppress) any exception that occurred — something plain finally cannot do.
This is a simplified mental model, not the literal bytecode Python generates, but it captures the essential guarantee: __enter__ before the block, __exit__ after, unconditionally.
# Conceptually similar to (simplified, __exit__ can also suppress exceptions):
manager = expr
value = manager.__enter__()
try:
name = value
# block body
finally:
manager.__exit__(*sys.exc_info())Key Takeaways
A context manager implements __enter__ and __exit__; with calls them automatically around a block for guaranteed setup/teardown.
__exit__ always runs when the block ends, whether normally, via return/break/continue, or due to an exception.
__exit__ receives (exc_type, exc_value, traceback), all None if no exception occurred; returning a truthy value suppresses that exception.
Multiple context managers can share one with statement (comma-separated); they enter left-to-right and exit right-to-left.
with gives deterministic cleanup, unlike relying on garbage collection, whose timing is an implementation detail (CPython: mainly refcounting).
f = open("data.txt") data = f.read() f.close() with open("data.txt") as f: data = f.read()
If f.read() raises, the manual close() call never runs; with guarantees the file is closed regardless of what happens inside the block.
class Resource: def __exit__(self, exc_type, exc_value, tb): cleanup() return True # accidentally suppresses ALL exceptions class Resource: def __exit__(self, exc_type, exc_value, tb): cleanup() return False # let exceptions propagate normally
Returning True unconditionally from __exit__ silently swallows every exception raised in the with block, including ones you never intended to suppress — only return True for exception types you deliberately want to ignore.
What two methods must an object implement to be a context manager?
__enter__(self), called when the with block begins, and __exit__(self, exc_type, exc_value, traceback), called when the block ends for any reason.
Under what circumstances does __exit__ get called?
Always, when the with block finishes — whether it completed normally, exited via return/break/continue, or an exception was raised inside it.
What does it mean if __exit__ returns True versus False?
Returning a truthy value suppresses any exception that occurred in the block (execution continues after the with statement as if nothing happened); returning a falsy value (False, None, or nothing) lets the exception propagate normally.
Why is with open(...) as f: preferred over manually calling open()/close()?
with guarantees f.close() runs even if an exception occurs inside the block or the code returns early; manual close() calls are skipped whenever an exception happens before reaching them.
If a with statement manages two context managers, with A() as a, B() as b:, in what order are they entered and exited?
Entered left to right (A's __enter__, then B's __enter__); exited right to left (B's __exit__, then A's __exit__) — the same order as writing them as nested with blocks.
What arguments does __exit__ receive when the with block completes without any exception?
exc_type, exc_value, and traceback are all None, signaling that no exception occurred.
What is printed, and does the program crash?
Prints 'enter' then 'exit <class 'ValueError'>' — __exit__ still runs and receives the exception type. Because __exit__ returns False, the ValueError is not suppressed, so it propagates and the program does crash with a traceback after those two lines print.
Write a context manager class ChangeDirectory that temporarily changes the working directory and restores it on exit, even if the block raises.
import os from pathlib import Path class ChangeDirectory: def __init__(self, path: str) -> None: self.path = Path(path) self._previous: Path | None = None def __enter__(self) -> Path: self._previous = Path.cwd() os.chdir(self.path) return self.path def __exit__(self, exc_type, exc_value, tb) -> bool: os.chdir(self._previous) return False
__enter__ records the current directory and changes into the target path; __exit__ always restores the original directory afterward, regardless of whether the block raised, and returns False so exceptions still propagate.
Write a context manager that suppresses only KeyError exceptions and lets all others propagate.
class IgnoreKeyError: def __enter__(self): return self def __exit__(self, exc_type, exc_value, tb): return exc_type is KeyError
__exit__ returns True (suppressing the exception) only when exc_type is exactly KeyError; for any other exception type, or no exception, it returns a falsy value so normal propagation (or normal completion) is unaffected.
class Loud:
def __enter__(self):
print("enter")
return self
def __exit__(self, *args):
print("exit", args[0])
return False
with Loud():
raise ValueError("boom")