Exceptions, Tracebacks, and Propagation
Exceptions, Tracebacks, and Propagation
After reading this chapter, you will master the mechanics of Python exceptions, parse multi-frame tracebacks, execute clean recovery with try, except, else, and finally, attach diagnostic metadata with add_note(), and handle modern concurrent exception bundles with ExceptionGroup.
Mental model
When an unhandled error occurs, CPython halts linear execution, instantiates an exception object, and begins unwinding the call stack frame by frame.
Exception Propagation Unwinding:
Call Stack:
[ main() ]
│
▼ calls
[ process_job() ]
│
▼ calls
[ fetch_payload() ] ──▶ Raises ConnectionResetError
│
├─ Frame fetch_payload has no try/except ──▶ Frame destroyed
│
├─ Frame process_job has try/except ConnectionResetError
│ │
│ ▼
│ [ except block executes ] ──▶ Error handled!
│
└─ (If unhandled: reaches top of stack, prints Traceback, exits 1)
The Four-Part Exception Lifecycle
A complete try statement has four distinct clauses:
try:
# Operations that might raise an exception
except SomeError as err:
# Executes ONLY if SomeError occurred in try
else:
# Executes ONLY if try completed WITHOUT any exception
finally:
# ALWAYS executes, regardless of success, exception, or early returnMinimal example
Save as exception_lifecycle.py:
# exception_lifecycle.py
import sys
def divide_metrics(total: float, count: int) -> float:
print(" [1. Entering try block]")
result = total / count
return result
def query_health(total: float, count: int) -> None:
try:
avg = divide_metrics(total, count)
except ZeroDivisionError as exc:
print(f" [2. Except block] Caught division error: {exc}")
else:
print(f" [3. Else block] Success! Average metric: {avg:.2f}")
finally:
print(" [4. Finally block] Cleaned up measurement resources.")
def main() -> None:
print("--- Test 1: Successful execution ---")
query_health(150.0, 5)
print("\n--- Test 2: Exception path ---")
query_health(150.0, 0)
if __name__ == "__main__":
main()Run via uv run python exception_lifecycle.py:
--- Test 1: Successful execution ---
[1. Entering try block]
[3. Else block] Success! Average metric: 30.00
[4. Finally block] Cleaned up measurement resources.
--- Test 2: Exception path ---
[1. Entering try block]
[2. Except block] Caught division error: division by zero
[4. Finally block] Cleaned up measurement resources.
Worked examples
Case 1: Attaching Diagnostics with Exception Notes (add_note())
Introduced in PEP 678 (Python 3.11+), add_note() allows intermediate functions to append contextual diagnostics (like transaction IDs, tenant names, or IP addresses) without modifying the original exception type:
# exception_notes.py
def connect_database(host: str, port: int) -> None:
# Simulate network connection failure
raise ConnectionRefusedError(f"Connection refused to {host}:{port}")
def execute_user_query(tenant_id: str, host: str, port: int) -> None:
try:
connect_database(host, port)
except ConnectionRefusedError as exc:
# Append diagnostic metadata to the exception
exc.add_note(f"Tenant ID: {tenant_id}")
exc.add_note(f"Target cluster: us-east-prod-db")
raise
if __name__ == "__main__":
try:
execute_user_query("tenant_9981", "10.0.4.12", 5432)
except ConnectionRefusedError as err:
print("Caught exception with notes:")
print(f"Message: {err}")
print("Notes:")
for note in getattr(err, "__notes__", []):
print(f" - {note}")Run:
uv run python exception_notes.pyOutput:
Caught exception with notes:
Message: Connection refused to 10.0.4.12:5432
Notes:
- Tenant ID: tenant_9981
- Target cluster: us-east-prod-db
Case 2: Concurrent Failure Bundling with ExceptionGroup
When performing parallel tasks (or running concurrent task groups in asyncio), multiple tasks can fail simultaneously. Python 3.11+ provides ExceptionGroup and the except* syntax to handle subsets of errors concurrently:
# exception_groups.py
def execute_cluster_rollout() -> None:
# Simulate multiple parallel node failures
errors: list[Exception] = [
TimeoutError("Node 01 timed out waiting for health check"),
ConnectionResetError("Node 02 reset SSH transport"),
TimeoutError("Node 04 timed out waiting for health check"),
]
raise ExceptionGroup("Cluster rollout experienced multiple node failures", errors)
def main() -> None:
try:
execute_cluster_rollout()
except* TimeoutError as eg:
print(f"Handled {len(eg.exceptions)} TimeoutErrors:")
for exc in eg.exceptions:
print(f" * {exc}")
except* ConnectionResetError as eg:
print(f"Handled {len(eg.exceptions)} ConnectionResetErrors:")
for exc in eg.exceptions:
print(f" * {exc}")
if __name__ == "__main__":
main()Run:
uv run python exception_groups.pyOutput:
Handled 2 TimeoutErrors:
* Node 01 timed out waiting for health check
* Node 04 timed out waiting for health check
Handled 1 ConnectionResetErrors:
* Node 02 reset SSH transport
Notice that both except* TimeoutError and except* ConnectionResetError executed, allowing each error category to be addressed independently from a single compound event.
Pitfalls
Pitfall 1: The Bare except: or except Exception: Anti-Pattern
Catching all exceptions indiscriminately swallows system-level interruptions like KeyboardInterrupt (Ctrl+C) and SystemExit:
# BAD: Bare except catches BaseException, breaking Ctrl+C!
try:
run_worker()
except:
pass
# BAD: Hides programming bugs (like NameError or TypoError)
try:
calculate_totals()
except Exception:
print("Something failed.")
# GOOD: Catch only expected, recoverable exception types
try:
calculate_totals()
except (KeyError, ValueError) as exc:
logger.warning(f"Malformed input data: {exc}")Pitfall 2: Forgetting to Re-Raise After Logging
Logging an error without re-raising or returning a fallback sentinel causes the program to continue in an inconsistent state:
# DANGEROUS:
def load_credentials():
try:
return read_vault_secret()
except VaultError as exc:
logger.error(f"Vault unreachable: {exc}")
# Implicitly returns None! Caller expects a dict, crashes later with AttributeError!
# FIX: Re-raise the exception or raise a domain exception
def load_credentials():
try:
return read_vault_secret()
except VaultError as exc:
logger.error(f"Vault unreachable: {exc}")
raiseExercises
- Write a function
parse_server_port(raw_port: str) -> intthat validates and converts a string into a port integer (1–65535), raisingValueErrorwith custom descriptive messages if conversion fails or the number is out of bounds. - Construct a
try/except/else/finallyblock that opens a simulated database connection, queries data intry, records metrics inelse, and ensures connection closure infinally. - Use
add_note()to attach the current local timestamp and host IP to any caughtOSError. - Create an
ExceptionGroupbundling three different exceptions and useexcept*to handle one type while allowing the others to propagate.
Further reading
- PEP 654: Exception Groups and except*.
- PEP 678: Enriching Exceptions with Notes.
- Python Standard Library: Built-in Exceptions.