Exception-Handling in Python – richtig gemacht

1. Überblick

Python ermutigt den EAFP-Stil: einfach machen, Fehler dann behandeln. Deshalb tauchen Exceptions überall in Python-Code auf. Falsche Behandlung führt zu verschluckten Fehlern, schwer lesbaren Tracebacks und doppelten Log-Einträgen.

Dieser Artikel deckt die Kerntechniken ab: try/except/else/finally, die richtige Exception abfangen, Custom-Exception-Hierarchien, Exception-Verkettung mit raise ... from, Logging, ExceptionGroup und Testing. Am Ende hast du klare Regeln für dein Projekt. Die Beispiele nutzen Python 3.11+.

2. Struktur von try/except/else/finally

Ein vollständiger Exception-Handler besteht aus vier Teilen:

def parse_quantity(raw: str) -> int:
    try:
        quantity = int(raw)
    except ValueError:
        print(f"Ungültige Menge: {raw!r}")
        return 0
    else:
        print("Erfolgreich geparst")
        return quantity
    finally:
        print("Parse beendet")


print(parse_quantity("3"))
print(parse_quantity("ba"))

Die Rolle jedes Teils:

  • try: enthält nur Code, der eine Exception werfen kann, hier int(raw).
  • except ValueError: läuft, wenn int() die Zeichenkette nicht parsen kann.
  • else: läuft, wenn try keine Exception wirft. Code für den “Happy Path” gehört hier statt in try.
  • finally: läuft immer, auch bei return oder Exception. Eignet sich für Ressourcen-Cleanup.

Ausgabe:

Erfolgreich geparst
Parse beendet
3
Ungültige Menge: 'ba'
Parse beendet
0

Beachte, dass finally läuft, bevor der Rückgabewert ausgegeben wird. Python führt finally aus, direkt bevor die Funktion beendet wird.

2.1. Warum else verwenden

Vergleiche diese zwei Schreibweisen:

# Nicht gut: try ist zu breit
try:
    quantity = int(raw)
    total = quantity * unit_price  # was ist, wenn unit_price vom falschen Typ ist?
except ValueError:
    total = 0

Wenn unit_price eine Zeichenkette ist, wirft die Multiplikation keine ValueError. Aber viele andere Operationen können ValueError werfen, wo du es nicht erwartest, und der Fehler wird als “ungültige Menge” falsch interpretiert. Halte try so eng wie möglich. Der Rest gehört in else.

3. Fange spezifische Exceptions ab, nicht alles

Der häufigste Fehler ist except: nackt oder except Exception: zu nutzen und zu ignorieren:

import time

def fetch_order_status(order_id: str) -> str:
    try:
        time.sleep(10)  # Simuliere langsamen API-Aufruf
        return "PAID"
    except:  # sehr schlecht
        return "UNKNOWN"

Ein nacktes except: fängt auch KeyboardInterrupt und SystemExit. Wenn du Ctrl+C drückst, läuft das Programm weiter und gibt "UNKNOWN" zurück, statt zu stoppen. Pythons Exception-Hierarchie ist hierarchisch organisiert:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── KeyError
      ├── OSError
      └── ...

Die Regel:

  • Fange die spezifischste Exception, die du wirklich behandeln kannst, z.B. KeyError, TimeoutError.
  • Nutze except Exception nur an den “Rändern” der Anwendung: Worker-Schleifen, Handler auf höchster Ebene. Wenn du es nutzt, logge immer.
  • Nutze nie except BaseException oder except: außer zum Weiterwurf.

Du kannst mehrere Typen in einem Tuple abfangen:

def read_price(prices: dict[str, str], sku: str) -> float:
    try:
        return float(prices[sku])
    except (KeyError, ValueError) as exc:
        print(f"Preis von {sku} konnte nicht gelesen werden: {type(exc).__name__}")
        return 0.0


print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
Preis von B2 konnte nicht gelesen werden: ValueError
0.0
Preis von Z9 konnte nicht gelesen werden: KeyError
0.0

4. Custom-Exceptions für deine Domäne definieren

Für geschäftliche Logik solltest du eine Root-Exception für dein Modul definieren, dann davon erben:

class PaymentError(Exception):
    """Root Exception für das Zahlungsmodul."""


class InsufficientFundsError(PaymentError):
    def __init__(self, order_id: str, balance: float, amount: float) -> None:
        super().__init__(
            f"Bestellung {order_id}: Saldo {balance} reicht nicht für {amount}"
        )
        self.order_id = order_id
        self.balance = balance
        self.amount = amount


class CardDeclinedError(PaymentError):
    pass


class PaymentService:
    def __init__(self, balance: float) -> None:
        self.balance = balance

    def charge(self, order_id: str, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFundsError(order_id, self.balance, amount)
        self.balance -= amount

Verwendung:

service = PaymentService(balance=100)

try:
    service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
    print(exc)
    print("Fehlbetrag:", exc.amount - exc.balance)
except PaymentError:
    print("Sonstiger Zahlungsfehler")
Bestellung ORD-42: Saldo 100 reicht nicht für 250
Fehlbetrag: 150

Diese Organisationsweise hat drei Vorteile:

  1. Aufrufer können PaymentError abfangen für alle Zahlungsfehler oder nur InsufficientFundsError für spezifische.
  2. Exception tragen strukturierte Daten (order_id, balance, amount), nicht nur eine Zeichenkette. Der Handler muss die Nachricht nicht parsen.
  3. Deine Fehler sind von Systemfehlern wie ValueError getrennt, werden nicht versehentlich gefangen.

Hinweis: Erbe von Exception, nicht BaseException. Klassenname sollte auf Error enden, nach PEP 8.

5. Exceptions mit raise ... from verketten

Wenn du einen Low-Level-Fehler in einen Domain-Fehler umwandelst, behalte die Originalursache:

import json


class ConfigError(Exception):
    pass


def load_config(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("Konfigurationsdatei ungültig") from exc


load_config("{port: 8080}")

Der Traceback zeigt beide Fehler:

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: Konfigurationsdatei ungültig

from exc setzt den Original-Fehler auf __cause__. Beim Debuggen sieht man sofort, wo der JSON-Fehler auftritt.

Wenn du from exc weglässt, verkettet Python immer noch über __context__, aber die Meldung wird “During handling of the above exception, another exception occurred”. Das suggeriert, dass der Handler selbst fehlgeschlagen ist, was nicht stimmt. Schreib from immer explizit.

Wenn die Original-Exception nur interne Details ist, nutze from None, um sie zu verbergen:

def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
    try:
        return tiers[customer_id]
    except KeyError:
        raise LookupError(f"Kunde {customer_id} nicht gefunden") from None

6. Ressourcen mit Context-Managern aufräumen

Bei Dateien, Verbindungen oder Locks ist with sicherer als selbstgeschriebenes try/finally:

from pathlib import Path

def count_orders(path: Path) -> int:
    with path.open(encoding="utf-8") as f:
        return sum(1 for line in f if line.strip())

Die Datei wird geschlossen, selbst wenn die Schleife eine Exception wirft.

Wenn du eine Exception wirklich ignorieren möchtest, ist contextlib.suppress deutlicher als try/except: pass:

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path("cache/orders.tmp").unlink()

Beim Lesen dieser Zeile versteht jeder: “Lösche die Datei falls vorhanden, sonst nichts.”

7. Logging und Re-Raising von Exceptions

Im except-Block nutze logger.exception(), um den vollständigen Traceback zu loggen:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")


def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
    try:
        service.charge(order_id, amount)
    except InsufficientFundsError:
        logger.warning("Bestellung %s abgelehnt, Guthaben nicht ausreichend", order_id)
        return False
    except PaymentError:
        logger.exception("Unbekannter Zahlungsfehler für Bestellung %s", order_id)
        raise
    return True

Wichtige Punkte:

  • logger.exception(...) ist gleichbedeutend mit logger.error(..., exc_info=True). Diese Funktion sollte nur im except aufgerufen werden.
  • Ein nacktes raise wirft die aktuelle Exception und behält den Traceback. Schreib nicht raise exc, sonst wird eine Zeile zum Traceback hinzugefügt.
  • Bekannte, erwartete Fehler (InsufficientFundsError) verdienen nur warning, keinen Traceback.

Hinweis: Vermeide “log und re-raise” auf jeder Schicht. Ein Fehler erscheint dann fünfmal im Log und erzeugt Rauschen. Logge nur, wo du den Fehler wirklich behandelst, oder am äußersten Rand der Anwendung.

8. Mehrere Fehler gleichzeitig mit ExceptionGroup

Ab Python 3.11 kannst du mehrere Exceptions gleichzeitig werfen. Das ist nützlich beim Validieren oder parallelen Tasks:

def validate_order(order: dict) -> None:
    errors: list[Exception] = []
    if order.get("quantity", 0) <= 0:
        errors.append(ValueError("quantity muss größer als 0 sein"))
    if not order.get("customer"):
        errors.append(KeyError("customer"))
    if errors:
        raise ExceptionGroup("Bestellung ungültig", errors)


try:
    validate_order({"quantity": 0})
except* ValueError as group:
    for e in group.exceptions:
        print("Wert falsch:", e)
except* KeyError as group:
    for e in group.exceptions:
        print("Feld fehlt:", e)
Wert falsch: quantity muss größer als 0 sein
Feld fehlt: 'customer'

Anders als normales except können mehrere except*-Zweige hintereinander laufen. Jeder Zweig erhält eine ExceptionGroup, die nur die Fehler enthält, die seinen Typ erfüllen. Nicht behandelte Fehler werden automatisch weitergeleitet.

asyncio.TaskGroup nutzt ebenfalls diesen Mechanismus. Wenn mehrere Sub-Tasks scheitern, erhältst du eine ExceptionGroup.

9. Exception-Verhalten mit pytest testen

Installiere pytest:

pip install pytest

Schreib Tests für PaymentService und load_config:

# test_payment.py
import pytest

from payment import (
    ConfigError,
    InsufficientFundsError,
    PaymentService,
    load_config,
)


def test_charge_raises_when_balance_is_not_enough():
    service = PaymentService(balance=100)

    with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
        service.charge("ORD-42", 250)

    assert exc_info.value.amount == 250
    assert service.balance == 100  # Saldo bleibt unverändert


def test_load_config_keeps_original_cause():
    with pytest.raises(ConfigError) as exc_info:
        load_config("{port: 8080}")

    assert exc_info.value.__cause__ is not None
    assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"

Erklärung:

  • pytest.raises(...) lässt den Test fehlschlagen, wenn keine Exception fliegt oder der Typ falsch ist.
  • match= nimmt einen Regex und matched gegen str(exception).
  • exc_info.value ist das echte Exception-Objekt. So kannst du Attribute wie amount, __cause__ prüfen.
  • Der erste Test prüft auch den Zustand nach dem Fehler: Saldo sollte gleich bleiben. Das wird oft vergessen.

Tests ausführen:

pytest -q
..                                                    [100%]
2 passed in 0.02s

10. Häufige Fehler

Exceptions stillschweigend schlucken.

try:
    service.charge(order_id, amount)
except Exception:
    pass  # Bestellung scheint erfolgreich, wurde aber nicht abgebucht

Solche Fehler sind fast unmöglich zu debuggen. Wenn du wirklich ignorieren musst, nutze suppress mit einem spezifischen Typ und schreib einen Kommentar warum.

return in finally.

def risky() -> str:
    try:
        raise RuntimeError("Daten verloren")
    finally:
        return "ok"  # Exception verschwindet!

return, break oder continue in finally unterdrücken die laufende Exception. Ab Python 3.14 gibt der Interpreter SyntaxWarning dafür aus. Schreib so etwas nie.

Allgemeine Exception werfen. raise Exception("fehler") zwingt Aufrufer, Exception zu fangen, also alles andere auch. Nutze Exception, die du hast (ValueError, TypeError) oder Custom-Exceptions.

Exceptions für normalen Fluss in Hot Loops. EAFP ist pythonisch. Aber wenn der “Fehler”-Fall oft vorkommt, ist dict.get() oder ein if-Check schneller und klarer:

# Besser, wenn key oft fehlt
discount = discounts.get(customer_id, 0)

assert zum Validieren von Input. assert wird bei python -O ausgeschaltet. Nutze raise ValueError(...) für Daten von außen.

11. Fazit

Korrektes Exception-Handling in Python dreht sich um wenige einfache Regeln:

  • Halte try eng, Code für den Erfolgsfall in else, Cleanup in finally oder with.
  • Fange die spezifischste Exception. Nur am Rand und immer loggen.
  • Baue dir eine Custom-Exception-Hierarchie für deine Domäne mit strukturierten Daten.
  • Nutze raise ... from exc für die Ursache und raise nackt für Re-Raising.
  • Nutze ExceptionGroup und except* bei mehreren gleichzeitigen Fehlern.
  • Teste mit pytest.raises, um Fehlertyp und Nachzustand zu prüfen.

Mit diesen Regeln werden deine Tracebacks lesbarer, deine Logs sauberer, und Fehler verschwinden nicht mehr lautlos.

pythonexceptionerror-handlingpytestbest-practices