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, hierint(raw).except ValueError: läuft, wennint()die Zeichenkette nicht parsen kann.else: läuft, wenntrykeine Exception wirft. Code für den “Happy Path” gehört hier statt intry.finally: läuft immer, auch beireturnoder 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 Exceptionnur an den “Rändern” der Anwendung: Worker-Schleifen, Handler auf höchster Ebene. Wenn du es nutzt, logge immer. - Nutze nie
except BaseExceptionoderexcept: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:
- Aufrufer können
PaymentErrorabfangen für alle Zahlungsfehler oder nurInsufficientFundsErrorfür spezifische. - Exception tragen strukturierte Daten (
order_id,balance,amount), nicht nur eine Zeichenkette. Der Handler muss die Nachricht nicht parsen. - Deine Fehler sind von Systemfehlern wie
ValueErrorgetrennt, werden nicht versehentlich gefangen.
Hinweis: Erbe von
Exception, nichtBaseException. Klassenname sollte aufErrorenden, 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 mitlogger.error(..., exc_info=True). Diese Funktion sollte nur imexceptaufgerufen werden.- Ein nacktes
raisewirft die aktuelle Exception und behält den Traceback. Schreib nichtraise exc, sonst wird eine Zeile zum Traceback hinzugefügt. - Bekannte, erwartete Fehler (
InsufficientFundsError) verdienen nurwarning, 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 gegenstr(exception).exc_info.valueist das echte Exception-Objekt. So kannst du Attribute wieamount,__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
tryeng, Code für den Erfolgsfall inelse, Cleanup infinallyoderwith. - 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 excfür die Ursache undraisenackt für Re-Raising. - Nutze
ExceptionGroupundexcept*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.