目次
6 章

エラーと例外処理

try/except/else/finally の役割、raise とユーザー定義例外、そして 3.11 がもたらした ExceptionGroup と except* までまとめます。

第5章 関数 — 引数パターン で関数の表現力を押さえました。本章は その関数が失敗するときにどう扱うか — 例外処理です。

本章の最後で扱う ExceptionGroup + except* は、第14章 非同期入門 (asyncio) と第18章 非同期の深層TaskGroup で再び出会います。非同期では同時に複数の作業が進むので、同時に複数の例外が発生し得ます。そこに ExceptionGroup が登場します。本章で形を先に押さえておくと第14章が軽くなります。

Python は 例外(exception) で誤りを表現する言語です。他の言語のようにエラーコードを返すスタイルではなく、「異常な状況は投げて、受ける人が捕まえる」が基本です。EAFP (Easier to Ask for Forgiveness than Permission) という呼び方をよくします。

もっとも単純な try / except #

基本 try/except
def divide(a: int, b: int) -> float:
    try:
        return a / b
    except ZeroDivisionError:
        return float("inf")

print(divide(10, 2))   # 5.0
print(divide(10, 0))   # inf

文法:

  • try: ブロックの中で例外が起こり得るコードを実行
  • except 例外型: で捕まえる
  • 捕まえられなかった例外は上に伝播 (caller に上がる)

LBYL vs EAFP #

他の言語は通常「書く前に確認」 (LBYL — Look Before You Leap) スタイルです。

LBYL スタイル
if b != 0:
    result = a / b
else:
    result = float("inf")

Python は EAFP がより慣用的です。

EAFP スタイル — Python らしい
try:
    result = a / b
except ZeroDivisionError:
    result = float("inf")

理由: Python は動的型付けなので 「条件を正確に事前確認する」 ことが難しい場面が多く、例外処理コストも (例外が頻発する場合でなければ) 低いからです。

複数の例外を捕まえる #

複数の例外
def parse_int(value: str) -> int | None:
    try:
        return int(value)
    except (ValueError, TypeError):
        return None

print(parse_int("42"))   # 42
print(parse_int("abc"))  # None
print(parse_int(None))   # None

(例外1, 例外2) の tuple でまとめて捕まえます。

別々に処理したいなら分離 #

それぞれ別の処理
try:
    do_work()
except ValueError as e:
    print(f"不正な値: {e}")
except KeyError as e:
    print(f"キーがない: {e}")
except Exception as e:
    print(f"それ以外: {e}")
    raise   # 再送出

as e で例外オブジェクトを受け取ります。メッセージ(str(e)) や原因(e.__cause__) などの情報を取り出せます。

elsefinally #

try には二つの追加節があります。

else / finally
def read_file(path: str) -> str:
    f = None
    try:
        f = open(path)
        data = f.read()
    except FileNotFoundError:
        return ""
    else:
        # 例外なしで try が終わったときだけ実行
        print(f"読み取り成功: {len(data)} bytes")
        return data
    finally:
        # 例外の有無に関わらず常に実行
        if f is not None:
            f.close()
  • elsetry が例外なしで終わったときだけ。「成功時のみやること」がある場合
  • finally — 例外の有無と無関係に 常に。リソース整理(ファイル close、ロック解放) に使う

上のコードは実は with 文でよりシンプルに書けます。

with — リソース整理は通常こちら
def read_file(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return ""

withfinally の close 責任を引き受けます。第10章 コンテキストマネージャ (with, contextlib) で詳しく扱います。

raise — 自分で投げる #

raise
def withdraw(balance: int, amount: int) -> int:
    if amount <= 0:
        raise ValueError("出金額は正の数でなければなりません")
    if amount > balance:
        raise ValueError(f"残高不足: {balance} < {amount}")
    return balance - amount

raise の後ろに例外インスタンス (またはクラス) を書きます。raise ValueError (クラスのみ) も可能ですが、メッセージを一緒に書く raise ValueError("...") が一般的です。

再送出 #

except の中で処理はしたが、呼び出し元にも通知したいとき。

re-raise
try:
    do_work()
except KeyError:
    log("キーがない発生")
    raise   # 引数なしで raise — 捕まえた例外をそのまま再送出

raise だけ書くと 現在捕まえている例外をそのまま 上に投げます。trace が保持されます。

raise ... from ... — 原因の連結 #

元の例外を別の例外で包みたいとき。

from で因果連結
def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return parse(f.read())
    except (OSError, ValueError) as e:
        raise ConfigError(f"設定の読み込み失敗: {path}") from e

from e があると traceback に 「この例外が起きた直接の原因は e」 という連結が表示されます。デバッグ時に役立ちます。

例外階層 #

Python の例外はクラス階層です。親を捕まえれば子も全部捕まります。

よく出会う階層 (簡略)
BaseException
 ├─ SystemExit          ← sys.exit()
 ├─ KeyboardInterrupt   ← Ctrl+C
 └─ Exception           ← 通常はここから下だけ捕まえる
     ├─ ValueError
     │   └─ UnicodeError
     ├─ TypeError
     ├─ LookupError
     │   ├─ KeyError
     │   └─ IndexError
     ├─ ArithmeticError
     │   └─ ZeroDivisionError
     ├─ OSError
     │   └─ FileNotFoundError
     └─ ...

重要なルール: 一般的なコードでは except Exception までで止めてください。except BaseException やすべてを受ける except:KeyboardInterrupt のようなシステムシグナルまで飲み込んでしまい、Ctrl+C が効かなくなる事故を生みます。

❌ 絶対 NG
try:
    risky()
except:           # bare except — KeyboardInterrupt も捕まる
    pass

# あるいは
except BaseException:   # 同じ問題
    pass
✅ すべての一般例外を捕まえる
try:
    risky()
except Exception as e:
    log(e)

ユーザー定義例外 #

ライブラリ / モジュールを作るときは、自前の例外クラス を定義しておくのがよいです。呼び出し元が正確に何を捕まえるか明示できます。

ユーザー定義例外
class AppError(Exception):
    """このアプリのすべての例外のベース。"""

class ConfigError(AppError):
    """設定関連エラー。"""

class DatabaseError(AppError):
    """DB 関連エラー。"""

class RowNotFoundError(DatabaseError):
    """必要な行がない。"""
    def __init__(self, table: str, key: str):
        super().__init__(f"{table}{key} が見つかりません")
        self.table = table
        self.key = key

呼び出し側は次の通りです。

呼び出し側
try:
    user = repo.get_user("u1")
except RowNotFoundError as e:
    return Response(status=404)
except DatabaseError as e:
    log_critical(e)
    return Response(status=500)

それぞれの呼び出し側で 捕まえたい分だけ 捕まえます。

finally の落とし穴 — return の優先順位 #

finallyreturn すると try の return を上書きします。ほぼアンチパターンです。

🚫 紛らわしいコード
def f() -> int:
    try:
        return 1
    finally:
        return 2   # こちらが勝つ

print(f())   # 2

finally副作用の後片付け だけにし、returnraise はできるだけしない のが好ましいです。

Exception Group — except* (3.11+) #

複数の作業が 同時に 失敗し得る状況(asyncio の TaskGroup、並列作業) で、一度に複数の例外をまとめて投げられる新しい仕組みが入りました。それが ExceptionGroup であり、それを捕まえる文法が except* です。

ExceptionGroup を作る
errors = ExceptionGroup(
    "複数の作業が失敗",
    [ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errors

except* はグループの中から 該当タイプの例外だけを選んで捕まえ、残りは再送出します。

except* の使用
try:
    raise ExceptionGroup(
        "複数の失敗",
        [ValueError("a"), TypeError("b"), ValueError("c")],
    )
except* ValueError as eg:
    print("値エラーたち:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("型エラーたち:", [str(e) for e in eg.exceptions])

いつ出会うのか。asyncio.TaskGroup で同時に複数の task を回すとき、そのうち二つ以上が失敗すると自動で ExceptionGroup が作られます。第14章 非同期入門 (asyncio) で本パターンに再び出会います。

assert — 開発中の前提チェック #

assert
def calc_average(items: list[int]) -> float:
    assert len(items) > 0, "空リストでは平均を求められない"
    return sum(items) / len(items)

assert 条件, メッセージ — 条件が False なら AssertionError が発生します。

注意: python -O (optimization) モードではすべての assert削除されます。 したがって次のようになります。

  • 開発中の前提チェック (「この時点では絶対に None ではないはず」のような)
  • ユーザー入力の検証 — これは常に通常の if + raise で行うべき
前提 vs 入力検証
def serve(user: User | None):
    # ✅ システム前提: 認証ミドルウェアが None を防いだはず
    assert user is not None, "auth middleware が抜けている?"

    # ❌ ユーザー入力を assert で防ぐのは NG
    # assert age >= 0, "負の年齢は不可"  ← -O で消える
    if age < 0:
        raise ValueError("年齢は 0 以上")

良い例外処理の感覚 #

最後にコードレビューでよく出るパターンを整理します。

1) 広すぎる捕捉をしない #

❌ 広すぎる
try:
    process(item)
except Exception:
    pass    # すべての例外を無視 — バグを隠す
✅ 捕まえたいものだけ
try:
    process(item)
except ValueError as e:
    log(f"不正な項目: {e}")

2) try ブロックは短く #

❌ try の中身が多すぎる
try:
    a = compute()
    b = transform(a)
    c = validate(b)
    d = save(c)
    return d
except SomeError:
    ...

SomeError がどの行で起きたか分かりません。怪しい一行だけ包んでください。

3) pass だけの except は疑う #

空の except: pass はほぼ必ずバグの始まりです。本当に無視してよい場合でも、理由をコメントで残すか、せめてロギングはしてください。運用環境のロギング標準は第31章 logging と観測性 で扱います。

4) 通常の流れに例外を使わない #

例外は 異常な状況 用です。通常の分岐まで try / except で書くと、コードを読む人が落とし穴にはまります。 (ただし Python は EAFP が慣用的なので、ある種の場合はそれが正常です — int("abc") のような変換失敗処理など。)

練習問題 #

  1. def safe_int(value: str) -> int | None: のシグネチャで int(value) を試み、ValueError なら None を返す関数を書いてください。TypeError (None が渡る場合) も一緒に捕まえるように tuple でまとめます。
  2. ユーザー定義例外階層 AppErrorValidationError / NotFoundError を作ってください。def find_user(id: int) -> User:id <= 0 なら ValidationError、DB に存在しなければ NotFoundError を投げるように書きます。呼び出し側で二つの例外をそれぞれ別のメッセージで処理するコードを書いてください。
  3. ExceptionGroup を直接作ってみてください。三つの作業の結果を集めて、二つ以上が ValueError なら ExceptionGroup("一括失敗", [...]) でまとめて投げ、呼び出し側は except* ValueError で受けてメッセージリストを出力します。

一行まとめ: Python は EAFP — 試してから捕まえるのが自然。try/except/else/finally の役割ははっきり違い、リソース整理は通常 with が読みやすい。except Exception までで止め、bare except と BaseException は禁止。ユーザー定義例外は階層で、assert は前提チェック用のみ。3.11+ の ExceptionGroup + except* は非同期の同時失敗用。

次の章 #

次の 第7章 モジュール、パッケージと pyproject.toml では、複数のファイルにコードを分ける方法import システム、モジュールとパッケージの違い、__init__.py__main__、そして pyproject.toml までを一箇所にまとめます。1部の最終章です。

X