Advanced Exception Handling in Python
Good error handling is the difference between a program that fails silently and one that fails predictably, informatively, and recoverably. Python's exception system goes well beyond basic try/except.
Full try/except/else/finally structure
import json
def load_config(path: str) -> dict:
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except FileNotFoundError:
print(f"File not found: {path}")
return {}
except json.JSONDecodeError as e:
print(f"Invalid JSON in {path}: {e.msg} (line {e.lineno})")
return {}
except (PermissionError, OSError) as e:
print(f"Access error: {e}")
raise # re-raise the original exception
else:
# Runs ONLY if no exception was raised
print(f"Config loaded: {len(data)} keys")
return data
finally:
# ALWAYS runs — with or without exception
print("Read attempt complete")
Python exception hierarchy
BaseException
├── SystemExit ← sys.exit()
├── KeyboardInterrupt ← Ctrl+C
├── GeneratorExit
└── Exception ← base of almost everything user-facing
├── ValueError
├── TypeError
├── AttributeError
├── NameError
├── IndexError
├── KeyError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── TimeoutError
├── RuntimeError
├── StopIteration
└── ...
Rule: always catch the most specific exception possible.
except Exception:hides bugs;except BaseException:also catchesKeyboardInterruptandSystemExit.
Custom exception hierarchies
class AppError(Exception):
"""Base class for all application exceptions."""
def __init__(self, message: str, code: int = 0):
super().__init__(message)
self.code = code
def __str__(self) -> str:
return f"[{self.code}] {super().__str__()}"
class ValidationError(AppError):
def __init__(self, field: str, value, reason: str):
super().__init__(f"Field '{field}': {reason}", code=400)
self.field = field
self.value = value
class ConversionError(AppError):
def __init__(self, src: str, dst: str, detail: str):
super().__init__(f"Cannot convert {src}→{dst}: {detail}", code=500)
self.src = src
self.dst = dst
# Usage
import re
def validate_email(email: str) -> str:
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
raise ValidationError('email', email, 'invalid format')
return email.lower()
try:
validate_email("not-an-email")
except ValidationError as e:
print(f"Validation failed: {e}")
print(f"Field: {e.field}, Code: {e.code}")
Exception chaining
# raise ... from ... — explicit chaining (preserves cause)
import socket
def connect_db(host: str, port: int):
try:
s = socket.create_connection((host, port), timeout=5)
return s
except OSError as original:
raise ConversionError('db', host, f"Cannot connect to {host}:{port}") \
from original
# Traceback shows both: the cause (OSError) and the new one (ConversionError)
# raise ... from None — suppress the original cause
def parse_int(text: str) -> int:
try:
return int(text)
except ValueError:
raise ValidationError('number', text, 'must be an integer') from None
# Inspect the exception chain
try:
connect_db("db.example.com", 5432)
except ConversionError as e:
print(f"Error: {e}")
if e.__cause__:
print(f"Root cause: {e.__cause__}")
Context managers for guaranteed cleanup
from contextlib import contextmanager, suppress
import tempfile
import os
@contextmanager
def temp_file(suffix: str = '.tmp'):
"""Creates a temporary file and deletes it on exit."""
fd, path = tempfile.mkstemp(suffix=suffix)
try:
os.close(fd)
yield path
finally:
if os.path.exists(path):
os.unlink(path)
print(f"Temp deleted: {path}")
with temp_file('.json') as tmp:
with open(tmp, 'w') as f:
f.write('{"key": "value"}')
print(f"Using temp: {tmp}")
# File is deleted here
# suppress — silently ignore specific exceptions
with suppress(FileNotFoundError):
os.remove('nonexistent_file.txt')
# nullcontext — conditional placeholder
from contextlib import nullcontext
def process(file, debug: bool = False):
ctx = open('debug.log', 'w') if debug else nullcontext()
with ctx as log:
content = file.read()
if log:
log.write(content)
return content
ExceptionGroup (Python 3.11+)
# ExceptionGroup bundles multiple exceptions raised "at the same time"
# (common with asyncio.TaskGroup or parallel processing)
def process_batch(items: list) -> list:
errors = []
results = []
for item in items:
try:
results.append(int(item))
except (ValueError, TypeError) as e:
errors.append(e)
if errors:
raise ExceptionGroup("Batch errors", errors)
return results
# except* — matches specific exception types within the group
try:
process_batch(["1", "two", "3", None, "5"])
except* ValueError as eg:
print(f"ValueError(s) in {len(eg.exceptions)} items:")
for e in eg.exceptions:
print(f" - {e}")
except* TypeError as eg:
print(f"TypeError(s): {eg.exceptions}")
traceback module — inspect and format errors
import traceback
import sys
def failing_function():
x = {}
return x['missing_key']
# Capture full traceback as a string
try:
failing_function()
except Exception:
tb_str = traceback.format_exc()
print("Captured traceback:")
print(tb_str)
# Parse frame-by-frame
exc_type, exc_val, exc_tb = sys.exc_info()
for frame in traceback.extract_tb(exc_tb):
print(f" {frame.filename}:{frame.lineno} in {frame.name}")
print(f" → {frame.line}")
# Log with traceback without re-raising
import logging
log = logging.getLogger(__name__)
try:
failing_function()
except Exception:
log.exception("Error processing request") # includes traceback automatically
Structured JSON error logging
import logging
import json
import traceback
from datetime import datetime, timezone
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
entry = {
'timestamp': datetime.now(timezone.utc).isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
}
if record.exc_info:
entry['exception'] = {
'type': record.exc_info[0].__name__,
'value': str(record.exc_info[1]),
'traceback': traceback.format_exception(*record.exc_info),
}
if hasattr(record, 'extra'):
entry.update(record.extra)
return json.dumps(entry, ensure_ascii=False)
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
log = logging.getLogger('myapp')
log.addHandler(handler)
log.setLevel(logging.DEBUG)
try:
raise ValidationError('age', -5, 'must be positive')
except ValidationError as e:
log.error(
"Validation failed",
exc_info=True,
extra={'extra': {'field': e.field, 'code': e.code}}
)
Result pattern — errors as values
from dataclasses import dataclass
from typing import Generic, TypeVar, Union
T = TypeVar('T')
E = TypeVar('E', bound=Exception)
@dataclass
class Ok(Generic[T]):
value: T
def is_ok(self) -> bool: return True
@dataclass
class Err(Generic[E]):
error: E
def is_ok(self) -> bool: return False
Result = Union[Ok[T], Err[E]]
def parse_age(text: str) -> Result:
try:
age = int(text)
if age < 0 or age > 150:
return Err(ValidationError('age', age, 'out of range'))
return Ok(age)
except ValueError:
return Err(ValidationError('age', text, 'not an integer'))
result = parse_age("25")
if result.is_ok():
print(f"Valid age: {result.value}")
else:
print(f"Error: {result.error}")
# Pattern matching (Python 3.10+)
match parse_age("abc"):
case Ok(value=v):
print(f"OK: {v}")
case Err(error=e):
print(f"Failed: {e}")
Best practices
- Catch the most specific exception first — clauses are evaluated top-to-bottom and the first match wins.
- Never do
except Exception: pass— it hides real bugs. Usecontextlib.suppress(SpecificType)for intentional silencing. - Use
raise ... from originalto preserve causality when re-raising; useraise ... from Noneonly when the original is an irrelevant implementation detail. log.exception()instead oflog.error()to automatically include the traceback.- Define your own exception hierarchy so callers can catch at different granularity levels (
except AppErrorvsexcept ValidationError). - The
elseblock in try/except is semantically clearer than placing success-path code at the end of thetryblock — use it to signal intent.
Related conversions
Frequent conversions across the catalogue: