Python の with ブロックで例外が消えるのはなぜか: __exit__ の戻り値と finally 内の return

__exit__ が真の値を返すと with 文は例外を抑制します。finally 内の return とは何が違うのか、狙った例外だけを握りつぶす抑制用コンテキストマネージャの書き方を Python 公式ドキュメントに沿って整理しました。

pythoncontext-managerexceptions

with ブロックの中で例外が起きたのに、プログラムはブロックの次の文から何事もなかったように進む。これはランタイムの不具合ではありません。with 文が例外を消してよい条件は言語リファレンスに定められていて、決め手は __exit__() の戻り値です。finally 節で return を実行しても例外は消えますが、こちらは別の規則によるもので、両者は混同しやすいところです。

この記事は 2026 年 10 月 18 日に確認した Python 3.15.0 のドキュメントを基準にしています。バージョンによって違う点は該当箇所で触れます。contextlib.suppress が例外グループに対応したのは 3.12、finally 内の return にコンパイラが警告を出すのは 3.14 からです。以下のコードのコメントに書いた結果は、Python 3.14.7 で例を実行して確認しました。説明の基準は引き続き 3.15.0 のドキュメントです。

exit が真の値を返すと例外は抑制される

言語リファレンスの with 文の節、手順 7 に規則があります。ブロックが例外によって終わった場合、その例外の型・値・トレースバックが __exit__() に渡されます。戻り値が偽なら例外は再送出され、真なら例外は抑制されて with 文の次の文から実行が続きます。例外なく終わった場合、__exit__() には None が 3 つ渡され、戻り値は無視されます。

同じ節にある等価なコードを見ると、さらにはっきりします。

# 言語リファレンスの等価コード(説明用。このままでは実行できない)
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)

判定は if not exit(...) なので、問われるのは結果が True かどうかではなく、真と評価されるかどうかです。return を書かずに終わるメソッドは None を返し、None は偽なので例外は伝播します。データモデルのドキュメントも同じ約束を書いています。例外を抑制したければ真の値を返し、そうでなければメソッドが終わったあと例外は通常どおり処理されます。

例外が抑制されると、ブロックの残りは実行されません。実行が再開するのは例外が起きた行の次ではなく、with 文の次です。

意図しない真の値

真理値判定の節によれば、クラスが False を返す __bool__() も 0 を返す __len__() も定義していないオブジェクトは、既定で真です。例外クラスや空でない文字列のような値は、そのため真になります。

class Quiet:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return exc_type  # 「例外の型をそのまま返すだけ」のつもりで書いたコード

with Quiet():
    1 / 0
    print("not reached")
print("after with")  # この行が実行される

例外がなければ exc_type は None なので何も起きません。例外が起きると exc_type は例外クラスで、これは真なので、すべての例外が抑制されます。__exit__() が何かの後始末メソッドの結果をそのまま返していて、その結果が真になりうる場合も同じです。

条件なしの return True はさらに範囲が広くなります。組み込み例外のドキュメントによると、KeyboardInterrupt は Exception を捕まえるコードにうっかり捕まらないよう BaseException を継承しています。しかし __exit__() はブロックを抜けるすべての例外を受け取り、そこには BaseException のサブクラスも含まれます。そのため常に真を返すマネージャは、ブロック内で起きた KeyboardInterrupt まで抑制します。

範囲を絞った抑制を書く

自分で書くなら、例外が実際に渡され、それが指定した型であるときだけ真を返します。

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 は抑制される

with Ignore(FileNotFoundError):
    int("x")  # ValueError は伝播する

issubclass はサブクラスも受け入れます。FileNotFoundError は OSError のサブクラスなので、Ignore(OSError) と書けばこれも抑制されます。渡す型が広いほど、跡形もなく消える失敗も増えます。

実際には、標準ライブラリにすでにある contextlib.suppress(3.4 で追加)を使えば足ります。

import os
from contextlib import suppress

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

ドキュメントはこれを try: ... except FileNotFoundError: pass と等価だと説明し、例外を完全に抑制するほかの仕組みと同じく、黙って処理を続けるのが正しいとわかっているごく特定のエラーにだけ使うよう注意しています。ブロックには失敗しうる呼び出しを 1 つだけ入れておくと、何を無視しているのかが読み取りやすくなります。

手書きの Ignore と違う点が 1 つあります。3.12 以降の suppress は、ブロックが BaseExceptionGroup を送出すると、指定した例外をグループから取り除き、残りを元のグループの derive() で作った新しいグループとして再送出します。上の Ignore は届いた例外そのものの型しか見ないので、FileNotFoundError を含むグループは抑制しません。

exit で再送出しない、そして送出すればその例外が勝つ

データモデルのドキュメントには、__exit__() は渡された例外を再送出すべきではなく、それは呼び出し側の責任だと書かれています。標準型のドキュメントのコンテキストマネージャ型の節も同じ内容を述べ、理由も示しています。偽の値を返す形にしておけば、コンテキスト管理のコードは __exit__() 自体が失敗したのかを見分けられます。抑制しないなら偽の値を返し、raise exc とは書きません。

一方、__exit__() が新たに送出した例外はそのまま伝播します。同じ節によれば、ブロックの例外を処理している最中に __exit__() が例外を送出すると、新しい例外が送出され、元の例外はその __context__ 属性に保存されます。

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')

ブロックの ValueError が失われるわけではありませんが、呼び出し側が受け取るのは RuntimeError で、except ValueError: のハンドラは実行されません。Java の try-with-resources は逆で、本体の例外を伝播させ、close() の例外を suppressed として付け加えます。こちらは Java の try-with-resources で close() の例外はどこへ行くのかで取り上げました。

@contextmanager では、捕まえて再送出しなければ抑制になる

contextlib.contextmanager で作ったマネージャでは、ブロックの例外がジェネレータ内の yield の位置で再送出されます。ドキュメントははっきり書いています。抑制するつもりはなく、ログを取ったり何かの処理をしたりするために捕まえたのなら、ジェネレータはその例外を再送出しなければなりません。そうしないと、ジェネレータのコンテキストマネージャは with 文に例外が処理されたと伝え、実行は with 文の次から続きます。

from contextlib import contextmanager

@contextmanager
def logged():
    try:
        yield
    except Exception as e:
        print("log:", repr(e))
        # raise がないので、例外はここで抑制される

with logged():
    1 / 0
print("after with")  # この行が実行される

ログを残しつつ例外も伝播させるには、except ブロックの最後に引数なしの raise を置きます。必ず実行したい後始末は、ドキュメントの例のように finally に書きます。

finally 内の return は別の仕組み

言語リファレンスの try 文の節は finally を次のように説明しています。処理されなかった例外は一時的に保存され、finally 節の最後で再送出されます。finally 節が return、break、continue を実行すると、保存された例外は破棄されます。リファレンスの例は 42 を返します。

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

ここにコンテキストマネージャは要りません。try 文そのものの規則で、あらゆる関数やループに当てはまります。__exit__() と違うのは、ソースコードを見ればわかる点です。Python 3.14 以降、コンパイラは return、break、continue が finally ブロックを抜けると SyntaxWarning を出します(What’s New in 3.14、PEP 765)。__exit__() が返す真の値は実行時に決まるので、このような警告はありません。

2 つの規則は @contextmanager のジェネレータの中で重なることがあります。

from contextlib import contextmanager

@contextmanager
def cleanup():
    try:
        yield
    finally:
        print("cleanup")
        return  # 3.14 以降は SyntaxWarning

ブロックが例外を送出すると、その例外は yield で再送出され、finally 内の return がそれを破棄します。ジェネレータは例外を再送出せずに終わるので、前節の contextmanager の規則により、例外は処理済みとして扱われます。後始末を保証するために書いた関数が、すべての例外を抑制してしまうわけです。finally から return を外し、早く抜ける必要があるなら try の本体で条件分岐にします。

まとめ

例外を消すもの いつ決まるか 警告
__exit__() が真の値を返す 実行時、戻り値によって なし
@contextmanager のジェネレータが捕まえて再送出しない 実行時、通った except の経路によって なし
finally 内の return、break、continue ソースコードの時点で決まっている 3.14 以降は SyntaxWarning
  • __exit__() は指定した例外の型のときだけ真を返す。return exc_type のような式は、例外があるたびに真になる。
  • 決まった例外だけを無視するなら contextlib.suppress がいちばん簡単で、ブロックには失敗しうる呼び出しだけを入れる。
  • 例外をそのまま通すなら、再送出せずに偽の値を返す。__exit__() で起きた例外はブロックの例外に取って代わり、元の例外は __context__ に残る。
  • @contextmanager のジェネレータで、ログを取るだけの例外は raise で再送出する。
  • finally に return を書かない。コンテキストマネージャがなくても例外を破棄する。

参考資料

いずれも 2026 年 10 月 18 日に確認しました(Python 3.15.0 のドキュメント)。

ブログへ