The Context Manager Protocol
A context manager is any object that defines a runtime context to enter before a block of code runs and to exit afterward, encapsulated as a pair of special (“dunder”) methods:
__enter__(self), run on the way in, whose return value is bound to theastarget; and__exit__(self, exc_type, exc_value, traceback), run on the way out — unconditionally, whether the block finished normally, raised, returned, or broke. The single defining feature of the protocol is the exit contract:__exit__receives the live exception that is propagating out of the block (or threeNones if none is), and if it returns a true value the exception is swallowed; any false/Nonereturn re-raises it. The Python reference states it plainly: “If an exception is supplied, and the method wishes to suppress the exception (i.e., prevent it from being propagated), it should return a true value. Otherwise, the exception will be processed normally upon exit from this method” (per the data model reference). This note owns the contract — what the methods are, what they must return, and thecontextlibecosystem built on top of them. Its sibling The with Statement and Cleanup owns the bytecode and control flow — howwithactually compiles and how the interpreter drives these calls.
The protocol was introduced by PEP 343 (“The ‘with’ Statement”, targeting Python 2.5, 2005), whose stated goal was “to make it possible to factor out standard uses of try/finally statements” (PEP 343). The asynchronous variant — __aenter__/__aexit__, driven by async with — arrived with PEP 492 (“Coroutines with async and await syntax”, Python 3.5). Everything below is verified against the CPython v3.14.5 documentation and standard-library source (as of 31 May 2026).
Mental Model
Think of a context manager as a balanced bracket around a block of code: __enter__ is the opening bracket (acquire the lock, open the file, begin the transaction, save the global state), and __exit__ is the matching closing bracket (release, close, commit-or-rollback, restore). The language guarantees the closing bracket fires exactly when the opening one succeeded — the same guarantee a try/finally gives you, but named once and reused everywhere. What a raw try/finally cannot do, and what makes the protocol more than syntactic sugar, is the exit method’s say over the exception: because __exit__ is handed the in-flight exception and its boolean return decides the exception’s fate, a context manager can examine an error and decide to suppress it — something a finally block can only do clumsily.
graph TD START["with cm as x:"] --> ENTER["x = type(cm).__enter__(cm)<br/>(return value bound to 'as' target)"] ENTER --> BODY["run the block body"] BODY -->|"normal completion / return / break"| EXITNORM["type(cm).__exit__(cm, None, None, None)<br/>return value IGNORED"] BODY -->|"raises EXC"| EXITEXC["type(cm).__exit__(cm, type(EXC), EXC, EXC.__traceback__)"] EXITEXC -->|"returns truthy"| SUPPRESS["exception SWALLOWED<br/>execution continues after with"] EXITEXC -->|"returns falsy / None"| RERAISE["exception RE-RAISED<br/>propagates out of with"] EXITNORM --> AFTER["execution continues after with"] SUPPRESS --> AFTER
Figure: The two exit paths of the protocol. On normal completion (left fork) __exit__ is called with three Nones and its return value is discarded — you cannot “suppress” a non-exception. On an exception (right fork) __exit__ is handed the exception triple and its truthiness is the verdict: truthy swallows, falsy re-raises. The insight to carry away: a return True written by accident in __exit__ silently eats every exception your block ever raises — the single most common context-manager bug.
The Two Methods, Precisely
__enter__(self)
__enter__ runs the setup. The reference says: “Enter the runtime context related to this object. The with statement will bind this method’s return value to the target(s) specified in the as clause of the statement, if any” (per data model). Two subtleties trip people up:
-
The
astarget is whatever__enter__returns — not the context manager itself. It is conventional for__enter__toreturn self(a file object opened byopen()returns the file), but it is not required.open()returns the file object;contextlib.suppress()returns the suppressor (itself); a database-connection context manager might return a fresh cursor rather than the connection. Code that assumeswith foo() as x:makesx is foo()is relying on convention, not the protocol. The classic gotcha isthreading.Lock:with lock as x:bindsxtoTrue(the boolean the lock’s__enter__/acquirereturns), not to the lock — soxis almost never useful, and you simply writewith lock:. -
If
__enter__raises,__exit__is never called. The bracket is only “balanced” once the opening bracket succeeded. The compound-statement reference is explicit: “Thewithstatement guarantees that if the__enter__()method returns without an error, then__exit__()will always be called” (per compound statements). The corollary — also stated there — is that an error during the assignment to theastarget is treated as an error inside the body, so__exit__does run for it, because__enter__had already returned successfully.
__exit__(self, exc_type, exc_value, traceback)
__exit__ runs the teardown and arbitrates the exception. Verbatim from the reference: “Exit the runtime context related to this object. The parameters describe the exception that caused the context to be exited. If the context was exited without an exception, all three arguments will be None. … Note that __exit__() methods should not reraise the passed-in exception; this is the caller’s responsibility” (per data model). Three rules govern its behaviour:
-
The three arguments are the exception triple: the exception’s class (
exc_type), the instance (exc_value), and its traceback object (traceback). On a clean exit all three areNone. This triple is the legacysys.exc_info()shape, retained for the protocol even though modern CPython tracks a single exception object internally. -
A true return suppresses; a false/
Nonereturn propagates. Crucially, a method that ends without an explicitreturnreturnsNone, which is falsy — so a__exit__that just does cleanup correctly lets exceptions through. You must deliberatelyreturn Trueto suppress, and you should only ever do so for an exception you actually inspected and chose to handle (see Failure Modes). -
Suppress by returning, never by re-raising. If
__exit__wants to replace the exception with a different one, it simplyraises the new one (which propagates as normal, with the original chained via__context__); it must not re-raise the passed-in exception, because the interpreter is already prepared to re-raise it for you and doing so manually corrupts the traceback.
__enter__/__exit__ are looked up by name on the type, not via a C-level type slot — there is no tp_enter/tp_exit in CPython, unlike tp_iter for the iterator protocol. This is confirmed two ways at the v3.14.5 tag: grepping the PyTypeObject/PyHeapTypeObject struct definitions in both Include/object.h and Include/cpython/object.h for tp_enter, tp_exit, __enter__, or __exit__ yields no matches (the struct simply has no such fields); and the compiler emits LOAD_SPECIAL — a by-name special-method lookup — rather than a slot call when compiling with. The context-manager protocol is therefore implemented entirely as a by-name lookup of __enter__/__exit__ on the type, with no dedicated slot anywhere in the C type object.
Why type-level lookup matters
Both methods are implicitly invoked special methods, so they are subject to Python’s special method lookup rule: the interpreter looks them up on the object’s type, bypassing the instance __dict__ and any __getattribute__ override. The reference’s own example for this rule is, fittingly, the context-manager methods: “This ensures that special methods like __enter__() and __exit__() are guaranteed to be discovered when implementing context managers, without being affected by instance dictionaries or __getattribute__() customization” (per special method lookup). The practical upshot: you cannot make an object a context manager by stuffing __enter__/__exit__ into its instance __dict__ (e.g. instance.__exit__ = lambda *a: None); the methods must live on the class. See Dunder Methods and the Data Model for the general rule.
A Concrete Class-Based Context Manager
import time
class Timer:
def __init__(self, label):
self.label = label
def __enter__(self):
self.start = time.perf_counter()
return self # bound to the `as` target
def __exit__(self, exc_type, exc_value, traceback):
elapsed = time.perf_counter() - self.start
print(f"{self.label}: {elapsed:.4f}s")
# implicit `return None` → falsy → exceptions propagate unchangedLine-by-line:
__enter__records a start time onselfandreturn self, sowith Timer("load") as t:bindstto the timer; a caller can readt.startinside the block.__exit__accepts the full exception triple even though this manager ignores it. It computes the elapsed time and prints it. Because it falls off the end with noreturn, it returnsNone— meaning if the block raised, the timer still prints (cleanup always runs) and the exception still propagates. That is exactly the behaviour you want for an observability wrapper: measure, then let the error fly.
A manager that does arbitrate the exception — a transaction wrapper — looks like this:
class Transaction:
def __init__(self, conn):
self.conn = conn
def __enter__(self):
self.conn.execute("BEGIN")
return self.conn # hand the caller the connection, not self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
self.conn.execute("COMMIT")
else:
self.conn.execute("ROLLBACK")
return False # never swallow — let the caller see the errorHere __exit__ branches on whether an exception occurred (exc_type is None) to commit or roll back, and explicitly return False so the error still surfaces to the caller after the rollback. This is the canonical database pattern, and it is why the protocol passes the exception to __exit__ at all: the teardown’s behaviour depends on how the block ended.
The Generator-Based Context Manager: contextlib.contextmanager
Writing a whole class for a setup/teardown pair is heavy. contextlib.contextmanager lets you write a context manager as a generator with a single yield — the code before the yield is the __enter__ body, the yielded value is the as target, and the code after the yield is the __exit__ body. This builds directly on the generator machinery covered in Generators and the yield Mechanism.
from contextlib import contextmanager
@contextmanager
def managed_resource(*args, **kwds):
resource = acquire_resource(*args, **kwds) # __enter__ body
try:
yield resource # value bound to `as`; block runs here
finally:
release_resource(resource) # __exit__ body — always runsThe mechanism (per the contextlib docs): “The function being decorated must return a generator-iterator when called. This iterator must yield exactly one value, which will be bound to the targets in the with statement’s as clause.” When the with block is entered, the decorator’s wrapper object calls next() on the generator, running it up to the yield and capturing the yielded value as the __enter__ return. When the block exits normally, the wrapper calls next() again to resume the generator past the yield, running the teardown. When the block exits with an exception, the wrapper does something subtler and crucial: “If an unhandled exception occurs in the block, it is reraised inside the generator at the point where the yield occurred” — i.e. the wrapper calls generator.throw(exc), which makes the yield expression raise the block’s exception. That is precisely why you wrap the yield in try/finally (or try/except): the finally guarantees teardown, and an except lets you handle and suppress the error. If the generator swallows the thrown exception and exits cleanly, __exit__ returns true (suppressing); if the exception propagates out of the generator, it propagates out of the with. The full suppression bookkeeping lives in Lib/contextlib.py’s _GeneratorContextManager.__exit__.
This behaviour is exactly what _GeneratorContextManager.__exit__ implements in Lib/contextlib.py (verified by reading the v3.14.5 source). On a clean exit (typ is None) it calls next(self.gen), expecting a StopIteration (the generator finishing after its teardown) and returning False; if the generator yields again it raises RuntimeError("generator didn't stop"). On an exception it calls self.gen.throw(value) and then disentangles the throw-protocol from the __exit__-protocol: if the same exception comes back out (exc is value) it returns False — not suppressed, because the generator did not absorb it; only if the generator swallows the thrown exception (so throw() raises a StopIteration signalling the generator finished) does it suppress. There is careful special handling for StopIteration and RuntimeError (PEP 479) so that a StopIteration raised inside the with body is not accidentally swallowed.
The async sibling contextlib.asynccontextmanager applies the same trick to an async def generator (one with yield), producing an async with-compatible object — covered alongside async with lowering in The with Statement and Cleanup and the coroutine machinery in Coroutines and the async await Protocol.
The contextlib Toolbox
Beyond @contextmanager, the module ships several ready-made managers and combinators (all per the contextlib docs):
-
closing(thing)— returns a manager whose__exit__callsthing.close(). Equivalent to a tiny@contextmanagerwithtry: yield thing; finally: thing.close(). Use it to make any object with aclose()method but no nativewithsupport usable in awithblock, e.g.with closing(urlopen(url)) as page:. -
suppress(*exceptions)— a manager whose__exit__returns true iff the in-flight exception is an instance of one of the named classes, swallowing exactly those.with suppress(FileNotFoundError): os.remove(path)replaces the four-linetry/except FileNotFoundError: pass. The docs note “This context manager is reentrant” (see reentrancy below). It is the rare, legitimate use of returning true from__exit__— and it is correct precisely because it inspects the exception type before suppressing. -
ExitStack— a manager that maintains a stack of cleanup callbacks, letting you enter a dynamic, data-driven number of context managers and guarantee they all unwind in reverse order.enter_context(cm)“Enters a new context manager and adds its__exit__()method to the callback stack. The return value is the result of the context manager’s own__enter__()method.”callback(fn, *args)registers a plain function (which “cannot suppress exceptions, as they are never passed the exception details”).pop_all()“Transfers the callback stack to a freshExitStackinstance and returns it” without invoking anything — the idiom for committing a half-built set of resources so they survive past the current block. Because “registered callbacks are invoked in the reverse order of registration, this ends up behaving as if multiple nestedwithstatements had been used.” The canonical use is opening a variable-length list of files:from contextlib import ExitStack with ExitStack() as stack: files = [stack.enter_context(open(fname)) for fname in filenames] # every file closes in reverse order at block exit, even if a later open() raisedThe async analogue is
AsyncExitStack, withenter_async_contextand async-aware unwinding. -
AbstractContextManager/AbstractAsyncContextManager— abstract base classes (see Abstract Base Classes) that provide a default__enter__returningselfand an abstract__exit__. Inheriting from them documents intent and letsissubclassstructural checks work. The async ABC mirrors them with__aenter__/__aexit__. -
ContextDecorator— a base that lets a context manager double as a function decorator (@cm), wrapping the whole decorated function in the context. The docs warn: “As the decorated function must be able to be called multiple times, the underlying context manager must support use in multiplewithstatements” — i.e. it must be reusable (below).
Reentrancy and Reusability
The contextlib docs draw a careful three-way distinction that catches people out:
-
Single-use managers can be entered once. A
@contextmanagergenerator is single-use: the generator is exhausted after one trip, so a secondwithon the same instance fails. (Calling the decorated function again makes a fresh generator — that is fine.) -
Reusable, not reentrant managers “support being used multiple times, but will fail … if the specific context manager instance has already been used in a containing
withstatement.”threading.LockandExitStackare the examples: you can use the same lock in two sequentialwithblocks, but nestingwith lock: with lock:on a plain (non-recursive) lock deadlocks. -
Reentrant managers “may also be used inside a
withstatement that is already using the same context manager” —threading.RLock,suppress(),redirect_stdout(),chdir(). The docs add a sharp caveat: “being reentrant is not the same thing as being thread safe.redirect_stdout(), for example, is definitely not thread safe.”
The lesson: reentrancy is a property of the specific manager, and you must check it before nesting a manager inside itself.
Failure Modes and Common Misunderstandings
-
Accidentally swallowing everything with
return True. A__exit__that ends withreturn True(or any truthy value) suppresses every exception the block raises, includingKeyboardInterruptand unrelated bugs. This is the protocol’s sharpest footgun. Cleanup-only managers must returnNone(just don’treturn). -
Re-raising the passed-in exception inside
__exit__. Writingraise exc_valueinside__exit__to “let it propagate” is wrong twice over: the interpreter will also re-raise it (double exception), and you mangle the traceback. To propagate, return falsy. To replace, raise a new exception. -
__enter__raising leaves no cleanup. If acquisition is multi-step and the second step can fail, the first step’s resource leaks because__exit__never runs. The fix is to make__enter__itself transactional (clean up its own partial work on failure) or to useExitStackso each acquired resource is registered the instant it succeeds. -
Assuming
as xgives you the manager. As covered above,xis__enter__’s return value. Reaching forx.some_manager_method()when__enter__returned something else (orTrue, for locks) is a common confusion. -
Async/sync mismatch. Using a plain
withon an object that only defines__aenter__/__aexit__raisesTypeError(no__enter__); the reverse fails too.async withrequires the async dunders;withrequires the sync ones. There is no automatic bridging.
Alternatives and When to Choose Them
A bare try/finally is the lower-level primitive the protocol abstracts; reach for it directly only for one-off cleanup that is not worth naming. A class-based manager is best when the manager holds meaningful state, when both __enter__ and __exit__ are substantial, or when you want it to also be reusable/reentrant. A @contextmanager generator is best for the common case of “setup, yield, teardown” with no reuse requirement — it is the most readable. ExitStack is the choice when the number of managers is dynamic. closing/suppress are micro-conveniences for their exact niches. For async code, the __aenter__/__aexit__ family and asynccontextmanager are mandatory — a sync manager cannot await inside its setup/teardown.
See Also
- The with Statement and Cleanup — the sibling note: how
withcompiles to bytecode and how the interpreter drives__enter__/__exit__(the mechanism this note’s contract describes). - Generators and the yield Mechanism — the engine under
@contextmanager. - Coroutines and the async await Protocol — what
async withand__aenter__/__aexit__plug into. - Dunder Methods and the Data Model — the general theory of special methods and type-level lookup.
- The Iterator Protocol — a sibling protocol (
__iter__/__next__) for comparison. - Abstract Base Classes —
AbstractContextManagerand friends. - Python Internals MOC, §13 “The Type System and Data-Model Protocols”.