Run it
All three exits observed from inside __exit__, the suppression case, and a return from the body that still runs the cleanup.
All three exits, observed from inside __exit__.
A return from inside the body cannot skip the cleanup either.
The generator form, and why the try/finally is not optional.
The protocol, in full
with expr as name: does four things. It evaluates expr. It calls __enter__() on the result and binds the return value to name — note that this is not necessarily the object itself, which is why open() can return a file and a lock can return None. It runs the body. Then it calls __exit__(exc_type, exc, traceback).
The last step is the point. It happens on a normal fall-through, on a return from inside the body, on a break, and on an exception. That is a stronger promise than try/finally written by hand, only because it is harder to forget.
Suppression, and why it is rare
A truthy return from __exit__ tells Python the exception has been handled, and execution continues after the block. It is a real feature with one common use — contextlib.suppress(FileNotFoundError) is a context manager whose entire job is to return True for the types you named.
Writing it yourself is usually a mistake, because a manager that swallows exceptions silently is a manager that hides bugs. The convention is to return None and let the exception through, doing cleanup on the way out. If you do suppress, suppress one specific type and nothing else.
contextlib, and the generator form
Writing a class with two dunder methods for a two-line cleanup is heavy, and contextlib.contextmanager removes it:
@contextmanager
def timed(label):
start = time.perf_counter()
try:
yield # the body runs here
finally:
print(label, time.perf_counter() - start)
Everything before the yield is __enter__; everything after is __exit__. The try/finally is not optional — without it, an exception in the body propagates out of the yield and the cleanup never runs, which quietly defeats the entire purpose.
ExitStack is the other one worth knowing: it manages a variable number of managers, so you can enter a list of files without a nested with per file.
The follow-up: why not just use try/finally
You can, and with compiles to roughly that. The difference is that the cleanup lives with the resource rather than with every call site, so it cannot be forgotten at the twelfth place the resource is used. It also composes: with a, b: nests correctly, and both exits run even if the first one raises.
The async counterpart is async with and __aenter__/__aexit__, which is what an async HTTP client or database session uses — same protocol, awaitable methods.
What to say out loud
with is the context manager protocol: __enter__ before the body, __exit__ after it, guaranteed, including on an exception or a return. __exit__ receives the exception details, and returning True from it suppresses the exception - which is exactly what contextlib.suppress does. I use it for anything with a cleanup that must happen.
Then stop. The commonest failure on a question like this is answering it correctly in one sentence and then talking for another minute until something wrong comes out.
What to notice while it runs
__exit__ runs in all three cases — that is the guarantee being bought.
- It is told the exception type, so cleanup can behave differently on failure.
- Returning
True swallows the exception; returning None lets it propagate.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
with a, b: is not the same as two separate blocks on failure. It nests, so if b's __enter__ raises, a's __exit__ still runs - which is the behaviour you want and the reason to prefer the comma form over two statements.
A @contextmanager generator can only be used once. The object it returns is a one-shot generator, so re-entering the same manager instance raises. Call the factory again instead, or write a class.
Suppressing broadly hides bugs. If __exit__ returns True unconditionally, every exception in every body disappears - including KeyboardInterrupt and typos. Suppress one named type or nothing.
The follow-ups interviewers ask
"What if __enter__ itself raises?" Then __exit__ is not called, because the block was never entered. This catches people: any resource acquired partway through __enter__ has to be cleaned up inside __enter__ itself. It is also why ExitStack exists for multi-resource setup.
"How do you manage a variable number of resources?" contextlib.ExitStack: enter each one into the stack and it unwinds them all in reverse on exit, however many there are. The alternative - a recursive or dynamically nested with - is not writable.
"What is the async equivalent?" async with, backed by __aenter__ and __aexit__, which are coroutines. Same protocol, awaitable methods, and it is what an async HTTP client or database session gives you.
Common wrong answers
"with catches exceptions." It does not. It guarantees the cleanup and lets the exception through, unless __exit__ returns something truthy - which is unusual and should be deliberate.
"It is just try/finally." Same effect, worse locality: the cleanup lives at every call site instead of with the resource. That is the argument, and the interviewer is usually listening for it.
"__exit__ receives the block's return value." It receives the exception type, value and traceback - or three Nones. It never sees the body's result.
Recap in one screen
- __exit__ runs in all three cases - that is the guarantee being bought.
- It is told the exception type, so cleanup can behave differently on failure.
- Returning True swallows the exception; returning None lets it propagate.
- The one-line answer: with calls __enter__, runs the body, and calls __exit__ however the body ends - normally, by return, or by exception.
- Worth trying: Delete the try/finally from guarded and re-run case 5. The exit line disappears - the bug that makes a hand-written context manager worse than none.
- Worth trying: Replace case 3 with contextlib.suppress(ValueError) and confirm it behaves the same. That is all suppress is.