How a Python with block swallows exceptions: __exit__ return values and return in finally

When __exit__ returns a true value, the with statement suppresses the exception. How that differs from return in finally, and how to write a suppressor that catches only what you mean.

pythoncontext-managerexceptions

An exception is raised inside a with block, yet the program carries on from the next statement after the block. That is not a runtime bug. The language reference spells out when a with statement may make an exception disappear: it depends on the value __exit__() returns. A finally clause that executes return also discards an exception, but by a different rule, and the two are easy to confuse.

This article follows the Python 3.15.0 documentation, checked on 18 October 2026. The version notes that apply are called out where they come up: contextlib.suppress gained exception group support in 3.12, and the compiler has warned about return in finally since 3.14. The results noted in the code comments below were checked by running the examples on Python 3.14.7; the explanations still follow the 3.15.0 documentation.

A true value from exit suppresses the exception

Step 7 of the with statement section of the language reference states the rule. If the block exits because of an exception, its type, value and traceback are passed to __exit__(). If the return value is false, the exception is re-raised; if it is true, the exception is suppressed and execution continues with the statement after the with statement. If the block exits without an exception, __exit__() receives three None arguments and its return value is ignored.

The equivalent code in the same section makes this concrete.

# Equivalent form from the language reference (sketch, not runnable as is)
manager = (EXPRESSION)
enter = manager.__enter__
exit = manager.__exit__
value = enter()
hit_except = False

try:
    TARGET = value
    SUITE
except:
    hit_except = True
    if not exit(*sys.exc_info()):
        raise
finally:
    if not hit_except:
        exit(None, None, None)

The test is if not exit(...), so what counts is truthiness, not whether the result is True. A method that ends without a return returns None, which is false, and the exception propagates. The data model documentation describes the same contract: to suppress the exception, return a true value; otherwise the exception is processed normally after the method returns.

When an exception is suppressed, the rest of the block does not run. Execution resumes after the with statement, not after the line that raised.

A truthy return you did not intend

The truth value testing section says that, by default, any object is true unless its class defines __bool__() returning False or __len__() returning zero. Values such as an exception class or a non-empty string are therefore true.

class Quiet:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return exc_type  # meant as "just pass along the exception type"

with Quiet():
    1 / 0
    print("not reached")
print("after with")  # this line runs

With no exception, exc_type is None and nothing changes. When an exception occurs, exc_type is the exception class, a true value, so every exception is suppressed. The same happens when __exit__() returns the result of some cleanup method and that result can be true.

return True without a condition is broader still. The built-in exceptions documentation says KeyboardInterrupt inherits from BaseException so that code catching Exception does not catch it by accident. __exit__() receives every exception that leaves the block, BaseException subclasses included, so a manager that always returns true also suppresses KeyboardInterrupt raised inside the block.

Writing a narrow suppressor

If you write it by hand, return true only when an exception arrived and it is one of the types you listed.

class Ignore:
    def __init__(self, *exceptions):
        self.exceptions = exceptions

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return exc_type is not None and issubclass(exc_type, self.exceptions)

with Ignore(FileNotFoundError):
    open("no-such-file.txt")
print("continues")  # FileNotFoundError was suppressed

with Ignore(FileNotFoundError):
    int("x")  # ValueError propagates

issubclass also accepts subclasses. FileNotFoundError is a subclass of OSError, so Ignore(OSError) would suppress it too. The broader the type you pass, the more failures disappear without a trace.

In practice the standard library already has this: contextlib.suppress (added in 3.4).

import os
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("somefile.tmp")

The documentation describes this as equivalent to try: ... except FileNotFoundError: pass, and cautions that, like any mechanism that completely suppresses exceptions, it should cover only very specific errors where silently continuing is known to be right. It is easier to see what is being ignored if the block holds only the one call that may fail.

There is one difference from the hand-written Ignore. Since 3.12, when the block raises a BaseExceptionGroup, suppress removes the matching exceptions from the group and re-raises the rest in a new group created with the original group’s derive(). Ignore above checks only the type of the exception that reaches it, so a group that contains a FileNotFoundError is not suppressed.

exit should not re-raise; if it raises, that exception wins

The data model documentation says __exit__() should not re-raise the exception passed in, because that is the caller’s responsibility. The Context Manager Types section of the standard types documentation says the same and gives the reason: returning a false value lets context management code tell whether __exit__() itself failed. If you do not want to suppress, return a false value; do not raise exc.

What __exit__() does raise, on the other hand, is what propagates. According to the same section, if __exit__() raises while handling an exception from the block, the new exception is raised and the original is stored in its __context__ attribute.

class Broken:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        raise RuntimeError("cleanup failed")

try:
    with Broken():
        raise ValueError("body failed")
except RuntimeError as e:
    print(repr(e))              # RuntimeError('cleanup failed')
    print(repr(e.__context__))  # ValueError('body failed')

The block’s ValueError is not lost, but the caller catches RuntimeError, and an except ValueError: handler does not run. Java’s try-with-resources is the opposite: the body’s exception is propagated and the close() exception is attached as suppressed. That is covered in where close() exceptions go in Java try-with-resources.

With @contextmanager, catching without re-raising suppresses

With contextlib.contextmanager, an exception from the block is re-raised inside the generator at the yield. The documentation is explicit: if you catch it only to log it or perform some action, without meaning to suppress it, the generator must re-raise it. Otherwise the generator context manager tells the with statement that the exception has been handled, and execution continues after the with statement.

from contextlib import contextmanager

@contextmanager
def logged():
    try:
        yield
    except Exception as e:
        print("log:", repr(e))
        # without raise, the exception is suppressed here

with logged():
    1 / 0
print("after with")  # this line runs

To log and still propagate, add a bare raise at the end of the except block. Cleanup that must always run belongs in finally, as in the documentation’s example.

return in finally is a different mechanism

The try statement section of the language reference describes finally like this. If an exception occurs and is not handled, it is saved temporarily and re-raised at the end of the finally clause. If the finally clause executes return, break or continue, the saved exception is discarded. The reference’s own example returns 42:

def f():
    try:
        1/0
    finally:
        return 42

This needs no context manager. It is the try statement’s own rule and applies to any function or loop. It also differs from __exit__() in that it can be seen in the source. Since Python 3.14 the compiler emits a SyntaxWarning when return, break or continue leaves a finally block (What’s New in 3.14, PEP 765). A true value returned by __exit__() is decided at run time, and no such warning exists for it.

The two rules can meet inside a @contextmanager generator.

from contextlib import contextmanager

@contextmanager
def cleanup():
    try:
        yield
    finally:
        print("cleanup")
        return  # SyntaxWarning on 3.14+

If the block raises, the exception is re-raised at the yield, and the return in finally discards it. The generator then ends without re-raising, which by the contextmanager rule above means the exception was handled. A function written only to guarantee cleanup ends up suppressing every exception. Remove the return from finally, and if you need early exit, use a condition in the try body instead.

Summary

What swallows the exception When it is decided Warning
__exit__() returns a true value At run time, by the return value None
@contextmanager generator catches without re-raising At run time, by the except path taken None
return, break or continue in finally Fixed by the source SyntaxWarning on 3.14+
  • In __exit__(), return true only for exception types you named. An expression such as return exc_type is true whenever an exception is present.
  • For a known set of exceptions, contextlib.suppress is the simplest choice, and the block should hold only the call that may fail.
  • To let an exception through, return a false value instead of re-raising it. An exception raised from __exit__() replaces the block’s exception, which is kept in __context__.
  • In a @contextmanager generator, an exception you only log must be re-raised with raise.
  • Do not put return in finally. It discards exceptions without any context manager involved.

References

Checked on 18 October 2026 (Python 3.15.0 documentation).

Back to the blog