except Exception を書いておけば全部拾える、と自分は思っていた。実際に10パターン試したら、4つは終了コード0のまま通過した。10個中4個、つまり40%。そのうち3つは警告すら出ない。CIは緑のまま、例外だけが消える。
この記事で持って帰れるのは3つ。例外がすり抜ける10個の型と、その一つひとつの実行ログ。すり抜けが起きる出口は3方向しかないという整理。そして自分のリポジトリで機械的に探すコマンド。
全部 macOS 15.7.4 / Python 3.14.5 / Node.js 26.0.0 で動かして、出力をそのまま貼っている。
| 書いたコード | 起きること | 直し方 |
|---|---|---|
except Exception で sys.exit() を囲む |
止まらずプロセスが終わる |
SystemExit を明示して再送出 |
except: (bare) |
Ctrl-C まで飲む |
except Exception に変える |
節の外で e を使う |
NameError |
別の変数に代入しておく |
except A or B |
B が拾われない | except (A, B) |
finally の中の return
|
例外が消える | 3.14の SyntaxWarning を潰す |
except Exception を先に書く |
下の節が実行されない | 狭いクラスから並べる |
__exit__ が True を返す |
with の例外が消える |
None を返す |
await を書き忘れる |
catch を素通り |
await か .catch()
|
setTimeout の中で throw
|
外の catch に入らない |
コールバックの中で囲む |
Promise.all |
2件目以降の失敗を捨てる | Promise.allSettled |
想定読者は、try-except / try-catch を日常的に書くけれど、どこまでが網の内側かを詰めて考えたことがない人。ログに出ないエラーを追った経験があると、たぶん7章目と9章目が刺さる。逆に BaseException と Exception の使い分けを説明できる人には、後半のJavaScript側しか新しい話がない。
- sys.exit(1) が try で止まらなかった
- bare except は Ctrl-C まで飲む
- except の
as eは、節を抜けると消える except A or Bは、B を拾わない- finally に return を書くと、例外が消える
- 並び順と
__exit__の戻り値、無言で捨てる2つ - await を1つ忘れると、catch は素通りする
- setTimeout の中の throw と、Promise.all の2件目
- 「raise e はトレースバックを消す」は再現しなかった
- 10個のうち、CIが緑のまま通るのは4個
- この検証の限界
- 今日・今週・今月やること
- まとめ
sys.exit(1) が try で止まらなかった
土台になる実験から。「異常系は全部ここで拾う」つもりの except Exception に、sys.exit(1) を通す。
# 「全部拾うつもり」の except Exception
import sys
def cleanup():
print("cleanup: 一時ファイルを消した")
try:
print("処理開始")
sys.exit(1) # ここで終わるつもりで書いた
print("ここは通らない")
except Exception as e:
print(f"except Exception が拾った: {type(e).__name__}")
finally:
cleanup()
print("関数の外まで来た")
出力がこれ。
処理開始
cleanup: 一時ファイルを消した
[exit=1]
except Exception が拾った が出ていない。関数の外まで来た も出ていない。finally だけが走って、プロセスは終了コード1で終わった。つまり sys.exit() は例外を投げているのに、except Exception の網には入らなかった。
理由は継承関係にある。実際に確かめる。
# 継承関係を print で確かめる
for cls in (SystemExit, KeyboardInterrupt, GeneratorExit, ValueError):
print(f"{cls.__name__:<18} Exception のサブクラス? {issubclass(cls, Exception)}")
print("MRO:", [c.__name__ for c in SystemExit.__mro__])
SystemExit Exception のサブクラス? False
KeyboardInterrupt Exception のサブクラス? False
GeneratorExit Exception のサブクラス? False
ValueError Exception のサブクラス? True
MRO: ['SystemExit', 'BaseException', 'object']
SystemExit の継承をたどると BaseException に直結していて、Exception を経由しない。公式の例外階層でも、プログラムを終わらせる系の3つだけが Exception の外に置かれている。「業務エラーを拾う網」と「終了要求」を混ぜないための設計で、これは意図的な線引き。
だから終了系を素通しするなら、先に名指しして投げ直す。
# 終了系だけ先に通す
import sys
try:
sys.exit(1)
except (SystemExit, KeyboardInterrupt):
print(" 終了系は素通しする")
raise
except Exception as e:
print(" 業務エラーだけここで処理:", e)
finally:
print(" finally: 後片付けは必ず走る")
終了系は素通しする
finally: 後片付けは必ず走る
[exit=1]
bare except は Ctrl-C まで飲む
except Exception が終了系を拾わないなら、コロンだけの except: はどうか。こちらは BaseException を拾う。Ctrl-C と同じ SIGINT を自分に送って比べた。
# bare except と except Exception に Ctrl-C 相当を投げ比べる
import os, signal
def send_ctrl_c():
os.kill(os.getpid(), signal.SIGINT) # Ctrl-C と同じシグナル
print("[A] bare except")
try:
send_ctrl_c()
except: # 「念のため全部拾う」つもりの1行
print(" 飲んだ: Ctrl-C がここで止まった")
print("[B] except Exception")
try:
send_ctrl_c()
except Exception:
print(" 飲んだ")
print(" ここは表示されない")
[A] bare except
飲んだ: Ctrl-C がここで止まった
[B] except Exception
Traceback (most recent call last):
File "p2_bare.py", line 15, in <module>
send_ctrl_c()
~~~~~~~~~~~^^
File "p2_bare.py", line 5, in send_ctrl_c
os.kill(os.getpid(), signal.SIGINT) # Ctrl-C と同じシグナル
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt
[exit=130]
[A] は Ctrl-C を飲み込んで、何事もなかったように次へ進んだ。[B] は素通りして終了コード130で落ちた。この130が正しい姿で、シェルは「SIGINTで終わった」と解釈できる。
これがループの中にあると、Ctrl-C を連打しても止まらないスクリプトができあがる。自分が過去に一番長く時間を溶かしたのも、この形だった。リンター側でも ruff の E722 が bare except を単独ルールとして持っている。
except の as e は、節を抜けると消える
例外オブジェクトを後で使いたくて、節の外で参照する書き方。
# except の as e を節の外で使う
try:
int("abc")
except ValueError as e:
print("節の中:", e)
print("節の外:", e) # ここが NameError
節の中: invalid literal for int() with base 10: 'abc'
Traceback (most recent call last):
File "p3_scope.py", line 7, in <module>
print("節の外:", e) # ここが NameError
^
NameError: name 'e' is not defined
変数が未定義になっている。Python 3 は except 節を抜けるときに、as で束縛した名前を明示的に削除する。言語リファレンスの try 文に、節の終わりで del e 相当が走ると書いてある。例外オブジェクトがトレースバック経由でフレームを掴んだまま残るのを避けるため。
後で使うなら、別の名前に移しておく。
# 節の中で別の変数に退避する
err = None
try:
int("abc")
except ValueError as e:
err = e # 名前を移し替える
print("節の外:", err)
節の外: invalid literal for int() with base 10: 'abc'
厄介なのは、e という名前が外側に既にある場合。削除は無条件に走るので、元の値まで巻き込まれる。
# 外側に既にある e が消えるか
e = "既存の値"
print("try の前:", e)
try:
int("abc")
except ValueError as e:
pass
try:
print("try の後:", e)
except NameError as err:
print("try の後: NameError ->", err)
try の前: 既存の値
try の後: NameError -> name 'e' is not defined
except を通っただけで、無関係だったはずの e が消えた。ループの中に置くと、2周目から NameError になる。
except A or B は、B を拾わない
複数の例外を1つの節で受けたい時、タプルではなく or でつないでしまう形。文法エラーにならないので通ってしまう。
# except A or B と書いてしまった場合
print("式の評価結果:", (ValueError or TypeError))
try:
None + 1 # TypeError が出る
except ValueError or TypeError as e:
print("拾えた:", type(e).__name__)
式の評価結果: <class 'ValueError'>
Traceback (most recent call last):
File "p4_or.py", line 5, in <module>
None + 1 # TypeError が出る
~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
1行目の出力が答えを言っている。ValueError or TypeError は例外の集合ではなく、ただの or 式。クラスオブジェクトは真なので左辺がそのまま返り、except ValueError と書いたのと同じになる。TypeError は最初から候補に入っていない。
正しくはタプルで囲む。except (ValueError, TypeError) as e と書けば両方拾う。ぶっちゃけ見た目が似ているので、レビューでも見落としやすい。
finally に return を書くと、例外が消える
後片付けのつもりで finally に return を1行足すと、投げたはずの例外が外へ出なくなる。
# finally の return
def load(path):
try:
return open(path).read()
except FileNotFoundError:
raise RuntimeError(f"設定ファイルが無い: {path}")
finally:
return "" # 後片付けのつもりで書いた1行
print("戻り値:", repr(load("/does/not/exist.conf")))
print("例外は飛んでこなかった")
p5_finally.py:8: SyntaxWarning: 'return' in a 'finally' block
return "" # 後片付けのつもりで書いた1行
戻り値: ''
例外は飛んでこなかった
[exit=0]
RuntimeError を送出しているのに、呼び出し側には空文字が返った。終了コードは0。設定ファイルが無いまま、アプリは正常に起動したことになる。
ここは2026年時点で状況が変わった箇所で、Python 3.14 から SyntaxWarning が出るようになった。PEP 765 が finally を抜ける return / break / continue を非推奨にして、3.14 の変更点に入っている。PEP のステータスは Final で、今は警告どまり。将来の禁止は明言されていないので、警告を放置しても動きは変わらない。
裏を返すと、3.13以前で書いたコードは無警告でこの挙動をしている。自分の手元では、警告をエラーに格上げすると即座に落とせた。
# SyntaxWarning をエラーに格上げして走らせる
python3 -W error::SyntaxWarning p5_finally.py
File "p5_finally.py", line 8
return "" # 後片付けのつもりで書いた1行
^^^^^^^^^
SyntaxError: 'return' in a 'finally' block
並び順と __exit__ の戻り値、無言で捨てる2つ
ここから2つは、警告も終了コードも出ない型。まず except の並び順。
# except の並び順
def parse(raw):
try:
return int(raw)
except Exception:
print(" -> except Exception に入った")
return None
except ValueError:
print(" -> except ValueError に入った")
return 0
print("parse('abc'):", parse("abc"))
-> except Exception に入った
parse('abc'): None
ValueError の節は書いてあるのに、一度も実行されない。except は上から順に issubclass で判定するので、広いクラスを先に置くと後ろが全部死ぬ。Python は到達不能な except 節を警告しない。呼び出し側は 0 を期待して None を受け取り、その先で TypeError になって初めて気づく。狭いクラスから順に並べれば直る。
もう1つがコンテキストマネージャ。__exit__ の戻り値が例外の運命を決める。
# __exit__ の戻り値
class Timer:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
print(f" __exit__ が受け取った例外: {exc_type.__name__ if exc_type else None}")
return True # 「後始末は成功した」つもりで書いた1行
with Timer():
raise RuntimeError("DBに繋がらない")
print("with を抜けた。例外はどこへ行った?")
__exit__ が受け取った例外: RuntimeError
with を抜けた。例外はどこへ行った?
[exit=0]
__exit__ は RuntimeError を確かに受け取っている。受け取った上で True を返したので、Python は「この例外は処理済み」と解釈して握り潰した。データモデルの仕様にそう書いてある。「後始末が成功した」という意味で True を返すと、意味が反転する。return を書かなければ None になり、例外はそのまま外へ出る。
# __exit__ は None を返す
class Timer:
def __enter__(self): return self
def __exit__(self, exc_type, exc, tb):
print(f" __exit__: {exc_type.__name__ if exc_type else None}")
# return しない = None = 例外はそのまま外へ
with Timer():
raise RuntimeError("DBに繋がらない")
__exit__: RuntimeError
Traceback (most recent call last):
File "fix_p7.py", line 9, in <module>
raise RuntimeError("DBに繋がらない")
RuntimeError: DBに繋がらない
await を1つ忘れると、catch は素通りする
ここからJavaScript。try で囲んだ非同期関数の await を1つ書き忘れる。
// await を1つ書き忘れた async 関数
async function saveUser(id) {
throw new Error(`保存に失敗: ${id}`);
}
function handler() {
try {
saveUser(42); // await を書き忘れた
console.log(" 保存できた(と思っている)");
} catch (e) {
console.log(" catch が拾った:", e.message);
}
}
process.on("unhandledRejection", (e) => {
console.log(" unhandledRejection まで飛んだ:", e.message);
});
handler();
setTimeout(() => console.log(" イベントループ1周後"), 0);
保存できた(と思っている)
unhandledRejection まで飛んだ: 保存に失敗: 42
イベントループ1周後
catch は動かず、成功したことになっている。async 関数は呼んだ瞬間に Promise を返すだけで、中の throw は Promise の reject になる。try ブロックは同期的にそこを抜けきっていて、後から来た reject を受け取る口がない。
process.on("unhandledRejection") を外すと、Node は最後に落とす。
Error: 保存に失敗: 42
at saveUser (file:///.../p8_nohandler.mjs:2:37)
Node.js v26.0.0
[実際のexit=1]
Node 15 以降は未処理の reject をプロセス終了として扱う。CLI の --unhandled-rejections の既定値が throw になっている。落ちるだけマシで、ブラウザや古い設定だとコンソールに出て終わる。
対策は await を書くか .catch() を繋ぐか。人力で見つけるのは無理なので、typescript-eslint の no-floating-promises に任せるのが早い。
setTimeout の中の throw と、Promise.all の2件目
残り2つも「時間軸のズレ」で説明がつく。setTimeout のコールバックで投げる。
// try の中で setTimeout を呼ぶ
process.on("uncaughtException", (e) => {
console.log(" uncaughtException まで飛んだ:", e.message);
});
try {
setTimeout(() => {
throw new Error("1秒後に失敗した");
}, 10);
console.log("try ブロックは即座に抜ける");
} catch (e) {
console.log(" catch が拾った:", e.message);
}
try ブロックは即座に抜ける
uncaughtException まで飛んだ: 1秒後に失敗した
setTimeout の仕事はコールバックを登録することだけ。10ms 後の実行は別のタイミングで、その時にはもう try を抜けている。囲むなら、コールバックの中に try を持ち込む。
// コールバックの中で捕まえる
setTimeout(() => {
try {
throw new Error("1秒後に失敗した");
} catch (e) {
console.log(" コールバック内の catch が拾った:", e.message);
}
}, 10);
最後が Promise.all。3つのうち2つを失敗させる。
// Promise.all と Promise.allSettled
const job = (name, ms, ok) =>
new Promise((res, rej) =>
setTimeout(() => (ok ? res(name) : rej(new Error(`${name} が失敗`))), ms));
try {
await Promise.all([job("A", 10, false), job("B", 30, false), job("C", 50, true)]);
} catch (e) {
console.log("Promise.all の catch が受け取った数: 1 件");
console.log(" 内容:", e.message);
}
const r = await Promise.allSettled([job("A", 10, false), job("B", 30, false), job("C", 50, true)]);
console.log("allSettled が返した件数:", r.length);
for (const x of r) console.log(" ", x.status, x.reason ? x.reason.message : x.value);
Promise.all の catch が受け取った数: 1 件
内容: A が失敗
allSettled が返した件数: 3
rejected A が失敗
rejected B が失敗
fulfilled C
A は10ms後、B は30ms後に失敗させている。2件 → 1件。失敗は2件あるのに、catch に届いたのは先に転んだ A だけ。Promise.all は最初の reject で確定し、残りの結果は捨てられる。MDN の Promise.all の言う「短絡」がこれ。バッチ処理でこれをやると、失敗率の集計が実態より小さく出る。全部の結果が要るなら Promise.allSettled に替える。3件そのまま返ってきている。
「raise e はトレースバックを消す」は再現しなかった
よく聞く注意に「例外を投げ直す時に raise e と書くとスタックトレースが失われる」がある。自分も避けてきたので、実際に測った。
# raise e と bare raise の traceback を行数で比べる
import traceback
def level3():
int("abc")
def level2():
level3()
def run(style):
try:
level2()
except ValueError as e:
if style == "raise e":
raise e # 変数を投げ直す
raise # bare raise
for style in ("raise e", "raise"):
try:
run(style)
except ValueError:
tb = traceback.format_exc()
frames = tb.count(" File ")
print(f"--- {style}: traceback {frames} フレーム ---")
--- raise e: traceback 5 フレーム ---
--- raise: traceback 4 フレーム ---
減るどころか1フレーム増えた。raise e の側は raise e と書いた行そのものがフレームとして追加され、level3 まで含む元のトレースは全部残っている。Python 3 の例外オブジェクトは __traceback__ を自分で持ち歩くので、変数経由でも情報が落ちない。
つまり Python 3 に限れば、この注意は当たらない。ただし raise e を選ぶ理由も無い。ノイズが1行増えるだけなので、投げ直しは bare raise でいい。原因の連鎖を明示的に切る raise ... from None は別の話で、こちらは本当に情報が消える。
10個のうち、CIが緑のまま通るのは4個
10パターンを、終了コードと標準エラー出力の有無で並べ直した。数字は実測。
| # | パターン | 終了コード | 警告・エラー出力 |
|---|---|---|---|
| 1 |
except Exception で sys.exit()
|
1 | 無し |
| 2 | bare except で Ctrl-C |
130 | 有り |
| 3 | 節の外で e を参照 |
1 | 有り |
| 4 | except A or B |
1 | 有り |
| 5 |
finally の return
|
0 | 有り (3.14の新警告) |
| 6 |
except の並び順 |
0 | 無し |
| 7 |
__exit__ が True
|
0 | 無し |
| 8 |
await 忘れ |
1 | 有り |
| 9 |
setTimeout 内の throw
|
1 | 有り |
| 10 |
Promise.all の2件目 |
0 | 無し |
終了コード0で通ったのが 5・6・7・10 の4個で、全体の40%。うち警告すら出ないのが 6・7・10 の3個で30%。テストが「例外が出ないこと」を確認していると、この3つは全部パスする。
危険度の順序も見えてくる。すぐ落ちる 2・3・4・8・9 は、その日のうちに気づく。放っておくと効くのは、静かに None や 0 を返し続ける 5・6・7・10 のほう。
出口で分類すると3方向しかない。Exception の親を通る (1・2)、try を抜けた後に来る (8・9・10)、捕まえた後に自分で捨てる (3・4・5・6・7)。冒頭の図はこの3分類を描いたもの。
この検証の限界
測ったのは macOS 15.7.4 / Python 3.14.5 / Node.js 26.0.0 の1環境だけ。特に5番目の SyntaxWarning は 3.14 で入った挙動なので、3.13以前では警告が出ない。手元のバージョンは python3 -V で先に確認したほうがいい。
JavaScript側の3つはランタイム依存が残る。ブラウザには process.on が無いし、未処理の reject をどう扱うかも実装差がある。Node 26 での結果として読んでほしい。
10パターンという数は自分の観測範囲での区切りで、網羅ではない。asyncio のタスク内例外、logging.error(e) でトレースが落ちる話、スレッドの中の例外は今回入れていない。どれも同じ「時間軸のズレ」か「捕まえた後に捨てる」に分類できる。
今日・今週・今月やること
今日 (5分)。自分のリポジトリで bare except を数える。
# bare except の件数をファイル別に出す
grep -rc --include='*.py' -E '^\s*except\s*:' . | grep -v ':0'
# finally の直後3行に return / break / continue が無いか
grep -rn --include='*.py' -A3 -E '^\s*finally\s*:' . | grep -E 'return|break|continue'
今週 (30分)。Python 3.14 に上げて、警告をエラーとして落とす。CIに1行足すだけで5番目の型が消える。
# SyntaxWarning をエラー扱いにしてテストを走らせる
python3 -W error::SyntaxWarning -m pytest
# __exit__ が True を返している箇所を洗い出す
grep -rn --include='*.py' -A8 'def __exit__' . | grep -E 'return True'
今月。JavaScript側をリンターに任せる。no-floating-promises を有効にすると8番目が機械的に落ちる。あわせて Promise.all の呼び出しを検索し、全件の結果が要る場所を allSettled に置き換える。Python側は ruff の E722 をCIに入れる。
# 全件の結果が必要な Promise.all を洗い出す
# --include はブレース展開しない。'*.{js,ts,mjs}' と書くと0件になる
grep -rn --include='*.js' --include='*.ts' --include='*.mjs' 'Promise.all(' . | wc -l
まとめ
except Exception は「全部」ではない。BaseException の直下にいる3つは網の外を通り、非同期の throw は try を抜けた後に来て、finally の return と __exit__ の True は捕まえた例外を自分の手で捨てる。
10パターンのうち4つは終了コード0で、3つは警告すら出ない。テストが緑でも、そこに例外が無かった証明にはならない。まず grep -rc --include='*.py' -E '^\s*except\s*:' . を1回叩くところから。
「raise e はトレースバックを消す」は Python 3 では起きなかった。5フレーム対4フレームで、むしろ増えている。避ける理由はノイズが1行増えることだけ。
